Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4232bf44d | |||
| 5b44318d55 | |||
| 7f70073301 |
@@ -29,9 +29,11 @@ uv sync
|
||||
- `mt5cli/`: Main package directory
|
||||
- `__init__.py`: Package initialization and exports (`detect_format`, `export_dataframe`)
|
||||
- `cli.py`: CLI application with typer-based commands for data export
|
||||
- `utils.py`: Constants, enums, parameter types, parsers, and export utilities
|
||||
- `__main__.py`: Entry point for `python -m mt5cli`
|
||||
- `tests/`: Comprehensive test suite (pytest-based)
|
||||
- `test_cli.py`: Tests for CLI commands, parameter types, and export functions
|
||||
- `test_cli.py`: Tests for CLI commands and collect-history behavior
|
||||
- `test_utils.py`: Tests for utility constants, parameter types, parsers, and export functions
|
||||
- `docs/`: MkDocs documentation with API reference
|
||||
- `docs/index.md`: Main documentation
|
||||
- `docs/api/`: Auto-generated API documentation for all modules
|
||||
|
||||
@@ -87,7 +87,46 @@ mt5cli -o history.db collect-history \
|
||||
--timeframe M1 --flags ALL --if-exists append --with-views
|
||||
```
|
||||
|
||||
History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `rates` table records the requested `timeframe` so appended runs at different timeframes remain distinguishable. The `positions_reconstructed` view aggregates trade deals by `position_id`, excludes positions without closing deals, and uses volume-weighted open/close prices; reversal deals (`DEAL_ENTRY_INOUT`) are reported via `volume_reversal` / `reversal_count` columns and do not contribute to the weighted prices.
|
||||
History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `rates` table records the requested `timeframe` so appended runs at different timeframes remain distinguishable. The `positions_reconstructed` view aggregates trade deals by `position_id`, excludes positions without closing-side entries, and uses volume-weighted open/close prices; reversal deals (`DEAL_ENTRY_INOUT`) are reported via `volume_reversal` / `reversal_count` columns.
|
||||
|
||||
### Incremental history SDK
|
||||
|
||||
For automated pipelines, use the importable incremental API instead of re-fetching fixed date ranges:
|
||||
|
||||
```python
|
||||
from pdmt5 import Mt5Config, Mt5DataClient
|
||||
from mt5cli import Dataset, update_history, update_history_with_config
|
||||
|
||||
# Reuse an already-connected pdmt5 client (does not open/close MT5)
|
||||
client = Mt5DataClient(config=Mt5Config(login=12345))
|
||||
client.initialize_and_login_mt5()
|
||||
try:
|
||||
update_history(
|
||||
client=client,
|
||||
output="history.db",
|
||||
symbols=["EURUSD", "GBPUSD"],
|
||||
datasets={Dataset.rates, Dataset.history_deals},
|
||||
timeframes=["M1", "H1"], # default: all fixed MT5 timeframes
|
||||
lookback_hours=24,
|
||||
create_rate_views=True,
|
||||
with_views=True,
|
||||
include_account_events=True,
|
||||
)
|
||||
finally:
|
||||
client.shutdown()
|
||||
|
||||
# Standalone wrapper that opens and closes MT5 for you
|
||||
update_history_with_config(
|
||||
output="history.db",
|
||||
symbols=["EURUSD"],
|
||||
config=Mt5Config(login=12345),
|
||||
)
|
||||
```
|
||||
|
||||
- **`collect-history`**: explicit date-range export into SQLite.
|
||||
- **`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.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
+39
-6
@@ -10,12 +10,22 @@ The mt5cli package consists of the following modules:
|
||||
|
||||
Command-line interface module providing typer-based commands for exporting MetaTrader 5 data to CSV, JSON, Parquet, and SQLite3 formats.
|
||||
|
||||
### [Utils](utils.md)
|
||||
|
||||
Utility module providing constants, enums, Click parameter types, and helper functions for parsing and exporting data.
|
||||
|
||||
### [SDK](sdk.md)
|
||||
|
||||
Programmatic SDK for read-only MetaTrader 5 data collection. Returns pandas DataFrames and provides `collect_history` for SQLite bulk collection.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The package follows a simple architecture built on top of pdmt5:
|
||||
|
||||
1. **CLI Layer** (`cli.py`): Typer application with subcommands for each data type, custom Click parameter types for datetime/timeframe/tick flags parsing, and format detection/export utilities.
|
||||
2. **Data Layer** (via `pdmt5`): Uses `Mt5DataClient` and `Mt5Config` from the pdmt5 package for all MetaTrader 5 data access.
|
||||
1. **CLI Layer** (`cli.py`): Typer application with subcommands that delegate to the SDK and export results.
|
||||
2. **SDK Layer** (`sdk.py`): Read-only data access functions, `Mt5CliClient`, and `collect_history` orchestration.
|
||||
3. **Utils Layer** (`utils.py`): Constants, enums, custom Click parameter types, parsing helpers, and format detection/export utilities.
|
||||
4. **Data Layer** (via `pdmt5`): Uses `Mt5DataClient` and `Mt5Config` from the pdmt5 package for all MetaTrader 5 data access.
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
@@ -47,15 +57,38 @@ mt5cli -o data.db --table symbols symbols --group "*USD*"
|
||||
## Python API
|
||||
|
||||
```python
|
||||
from mt5cli import detect_format, export_dataframe
|
||||
import pandas as pd
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli import (
|
||||
Mt5CliClient,
|
||||
collect_history,
|
||||
copy_rates_range,
|
||||
detect_format,
|
||||
export_dataframe,
|
||||
)
|
||||
|
||||
# Fetch rates programmatically
|
||||
rates = copy_rates_range(
|
||||
"EURUSD",
|
||||
timeframe="H1",
|
||||
date_from="2024-01-01",
|
||||
date_to="2024-02-01",
|
||||
)
|
||||
|
||||
# Detect output format from file extension
|
||||
fmt = detect_format(Path("output.parquet")) # Returns "parquet"
|
||||
|
||||
# Export a DataFrame
|
||||
df = pd.DataFrame({"symbol": ["EURUSD"], "bid": [1.1234]})
|
||||
export_dataframe(df, Path("output.csv"), "csv")
|
||||
export_dataframe(rates, Path("output.csv"), "csv")
|
||||
|
||||
# Collect history into SQLite
|
||||
collect_history(
|
||||
Path("history.db"),
|
||||
symbols=["EURUSD"],
|
||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
||||
)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# SDK Module
|
||||
|
||||
::: mt5cli.sdk
|
||||
@@ -0,0 +1,131 @@
|
||||
# SQLite History Module
|
||||
|
||||
::: mt5cli.sqlite_history
|
||||
|
||||
## `collect-history` schema
|
||||
|
||||
The `collect-history` command (and the matching `collect_history` SDK function) writes
|
||||
selected MT5 datasets into one SQLite database. Each dataset becomes a table; column
|
||||
names and types mirror the pdmt5 DataFrame schema for that export, with two additions:
|
||||
|
||||
- `symbol` is prepended on every table.
|
||||
- `timeframe` is prepended on `rates` so appended runs at different bar sizes stay
|
||||
distinguishable.
|
||||
|
||||
SQLite does not declare foreign keys. Rows are linked logically by `symbol`, time
|
||||
windows, and (for deals) `position_id` / `order`. Duplicate rows are removed on
|
||||
append using dataset-specific keys (for example `ticket` on history tables, or
|
||||
`(symbol, timeframe, time)` on rates).
|
||||
|
||||
Optional views are created when `--with-views` is set and the `history-deals` dataset
|
||||
was written.
|
||||
|
||||
### Entity-relationship diagram
|
||||
|
||||
Sample layout for a full collection with `--with-views`:
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
rates {
|
||||
TEXT symbol "dedup key"
|
||||
INTEGER timeframe "dedup key"
|
||||
TEXT time "dedup key"
|
||||
REAL open
|
||||
REAL high
|
||||
REAL low
|
||||
REAL close
|
||||
INTEGER tick_volume
|
||||
INTEGER spread
|
||||
INTEGER real_volume
|
||||
}
|
||||
|
||||
ticks {
|
||||
TEXT symbol "dedup key"
|
||||
TEXT time "dedup key"
|
||||
INTEGER time_msc "dedup key (preferred)"
|
||||
REAL bid
|
||||
REAL ask
|
||||
REAL last
|
||||
INTEGER volume
|
||||
INTEGER flags
|
||||
REAL volume_real
|
||||
}
|
||||
|
||||
history_orders {
|
||||
INTEGER ticket "dedup key"
|
||||
TEXT symbol
|
||||
TEXT time
|
||||
INTEGER type
|
||||
INTEGER state
|
||||
REAL volume_initial
|
||||
REAL price_open
|
||||
REAL price_current
|
||||
INTEGER magic
|
||||
}
|
||||
|
||||
history_deals {
|
||||
INTEGER ticket "dedup key"
|
||||
INTEGER order
|
||||
INTEGER position_id "groups position view"
|
||||
TEXT symbol
|
||||
TEXT time
|
||||
INTEGER type "0/1 trade, else cash event"
|
||||
INTEGER entry "0 IN, 1 OUT, 2 INOUT, 3 OUT_BY"
|
||||
REAL volume
|
||||
REAL price
|
||||
REAL profit
|
||||
REAL commission
|
||||
REAL swap
|
||||
REAL fee
|
||||
}
|
||||
|
||||
cash_events {
|
||||
INTEGER ticket
|
||||
TEXT symbol
|
||||
TEXT time
|
||||
INTEGER type
|
||||
REAL profit
|
||||
}
|
||||
|
||||
positions_reconstructed {
|
||||
INTEGER position_id
|
||||
TEXT symbol
|
||||
TEXT open_time
|
||||
TEXT close_time
|
||||
INTEGER direction
|
||||
REAL volume_open
|
||||
REAL volume_close
|
||||
REAL volume_reversal
|
||||
REAL open_price
|
||||
REAL close_price
|
||||
REAL total_profit
|
||||
INTEGER reversal_count
|
||||
INTEGER deals_count
|
||||
}
|
||||
|
||||
rates ||--o{ history_deals : "symbol (logical)"
|
||||
ticks ||--o{ history_deals : "symbol (logical)"
|
||||
history_orders ||--o{ history_deals : "order ~ ticket (logical)"
|
||||
history_deals ||--|| cash_events : "VIEW: type NOT IN (0,1)"
|
||||
history_deals ||--o{ positions_reconstructed : "VIEW: GROUP BY position_id"
|
||||
```
|
||||
|
||||
### Tables and views
|
||||
|
||||
| Object | Kind | Source | Notes |
|
||||
| ------------------------- | ----- | -------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `rates` | table | `copy_rates_range` | Indexed on `(symbol, timeframe, time)` when columns exist. |
|
||||
| `ticks` | table | `copy_ticks_range` | Indexed on `(symbol, time)` when columns exist. |
|
||||
| `history_orders` | table | `history_orders_get` | Fetched per `--symbol`, then concatenated. |
|
||||
| `history_deals` | table | `history_deals_get` | Fetched per `--symbol`, then concatenated. Indexed on `(position_id, symbol)` when present. |
|
||||
| `cash_events` | view | `history_deals` | Non-trade deal types (deposits, balance ops, etc.). Requires `type` column. |
|
||||
| `positions_reconstructed` | view | `history_deals` | One row per closed `position_id`; volume-weighted prices and reversal stats. |
|
||||
|
||||
Column sets can vary with terminal and pdmt5 version. Views are skipped with a warning
|
||||
when required columns are missing.
|
||||
|
||||
### Incremental collection
|
||||
|
||||
The `update_history` SDK path uses the same base tables and optional
|
||||
`cash_events` / `positions_reconstructed` views. It additionally maintains
|
||||
`rate_<symbol>__<timeframe>` compatibility views when `create_rate_views=True`.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Utils Module
|
||||
|
||||
::: mt5cli.utils
|
||||
+43
-1
@@ -20,6 +20,44 @@ mt5cli is a CLI application that exports MetaTrader 5 trading data to multiple f
|
||||
pip install mt5cli
|
||||
```
|
||||
|
||||
## Programmatic usage / SDK usage
|
||||
|
||||
mt5cli can be used as a small Python SDK for read-only MetaTrader 5 data collection. SDK functions return pandas DataFrames without writing files. Use `export_dataframe` when you need to persist results.
|
||||
|
||||
```python
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli import Mt5CliClient, collect_history, copy_rates_range, export_dataframe
|
||||
|
||||
# One-off fetch with module-level helpers
|
||||
rates = copy_rates_range(
|
||||
"EURUSD",
|
||||
timeframe="H1",
|
||||
date_from="2024-01-01",
|
||||
date_to="2024-02-01",
|
||||
)
|
||||
export_dataframe(rates, Path("rates.csv"), "csv")
|
||||
|
||||
# Reuse one MT5 connection for multiple calls
|
||||
with Mt5CliClient(login=12345, password="secret", server="Broker-Demo") as client:
|
||||
account = client.account_info()
|
||||
positions = client.positions()
|
||||
|
||||
# Bulk SQLite collection (same behavior as the collect-history CLI command)
|
||||
collect_history(
|
||||
Path("history.db"),
|
||||
symbols=["EURUSD", "GBPUSD"],
|
||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
||||
timeframe="M1",
|
||||
flags="ALL",
|
||||
with_views=True,
|
||||
)
|
||||
```
|
||||
|
||||
Timeframes, tick flags, and ISO 8601 date strings are accepted wherever noted in the SDK API.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -114,6 +152,8 @@ mt5cli -o history.db collect-history \
|
||||
|
||||
History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `positions_reconstructed` view excludes positions with no closing deal, uses volume-weighted open/close prices, and reports reversal deals (`DEAL_ENTRY_INOUT`) via `volume_reversal` / `reversal_count`.
|
||||
|
||||
See the [SQLite History schema diagram](api/sqlite_history.md#entity-relationship-diagram) for a sample ER layout of the resulting database.
|
||||
|
||||
## Global Options
|
||||
|
||||
| Option | Description |
|
||||
@@ -138,7 +178,9 @@ History orders and deals are fetched per symbol and concatenated, so the symbol
|
||||
|
||||
Browse the API documentation for detailed module information:
|
||||
|
||||
- [CLI Module](api/cli.md) - CLI application with export commands and utility functions
|
||||
- [CLI Module](api/cli.md) - CLI application with export commands
|
||||
- [SDK Module](api/sdk.md) - Programmatic read-only data collection API
|
||||
- [Utils Module](api/utils.md) - Constants, parameter types, parsers, and export utilities
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ theme:
|
||||
features:
|
||||
- content.code.annotate
|
||||
- content.code.copy
|
||||
- content.code.mermaid
|
||||
- navigation.indexes
|
||||
- navigation.sections
|
||||
- navigation.tabs
|
||||
@@ -56,6 +57,9 @@ nav:
|
||||
- API Reference:
|
||||
- Overview: api/index.md
|
||||
- CLI: api/cli.md
|
||||
- SDK: api/sdk.md
|
||||
- SQLite History: api/sqlite_history.md
|
||||
- Utils: api/utils.md
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
|
||||
+52
-2
@@ -1,12 +1,62 @@
|
||||
"""mt5cli: Command-line tool for MetaTrader 5."""
|
||||
"""mt5cli: Command-line tool and SDK for MetaTrader 5."""
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
from .cli import detect_format, export_dataframe
|
||||
from .sdk import (
|
||||
Mt5CliClient,
|
||||
account_info,
|
||||
build_config,
|
||||
collect_history,
|
||||
copy_rates_from,
|
||||
copy_rates_from_pos,
|
||||
copy_rates_range,
|
||||
copy_ticks_from,
|
||||
copy_ticks_range,
|
||||
history_deals,
|
||||
history_orders,
|
||||
last_error,
|
||||
market_book,
|
||||
orders,
|
||||
positions,
|
||||
symbol_info,
|
||||
symbol_info_tick,
|
||||
symbols,
|
||||
terminal_info,
|
||||
update_history,
|
||||
update_history_with_config,
|
||||
)
|
||||
from .sdk import (
|
||||
version as mt5_version,
|
||||
)
|
||||
from .utils import Dataset, IfExists, detect_format, export_dataframe
|
||||
|
||||
__version__ = version(__package__) if __package__ else None
|
||||
|
||||
__all__ = [
|
||||
"Dataset",
|
||||
"IfExists",
|
||||
"Mt5CliClient",
|
||||
"account_info",
|
||||
"build_config",
|
||||
"collect_history",
|
||||
"copy_rates_from",
|
||||
"copy_rates_from_pos",
|
||||
"copy_rates_range",
|
||||
"copy_ticks_from",
|
||||
"copy_ticks_range",
|
||||
"detect_format",
|
||||
"export_dataframe",
|
||||
"history_deals",
|
||||
"history_orders",
|
||||
"last_error",
|
||||
"market_book",
|
||||
"mt5_version",
|
||||
"orders",
|
||||
"positions",
|
||||
"symbol_info",
|
||||
"symbol_info_tick",
|
||||
"symbols",
|
||||
"terminal_info",
|
||||
"update_history",
|
||||
"update_history_with_config",
|
||||
]
|
||||
|
||||
+96
-936
File diff suppressed because it is too large
Load Diff
+917
@@ -0,0 +1,917 @@
|
||||
"""Programmatic SDK for MetaTrader 5 data collection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Self, TypeVar
|
||||
|
||||
from pdmt5 import Mt5Config, Mt5DataClient
|
||||
|
||||
from .sqlite_history import (
|
||||
create_cash_events_view,
|
||||
create_history_indexes,
|
||||
create_positions_reconstructed_view,
|
||||
resolve_history_datasets,
|
||||
resolve_history_tick_flags,
|
||||
resolve_history_timeframes,
|
||||
write_collected_datasets,
|
||||
write_incremental_datasets,
|
||||
)
|
||||
from .utils import (
|
||||
Dataset,
|
||||
IfExists,
|
||||
parse_datetime,
|
||||
parse_tick_flags,
|
||||
parse_timeframe,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
|
||||
import pandas as pd
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"Mt5CliClient",
|
||||
"account_info",
|
||||
"build_config",
|
||||
"collect_history",
|
||||
"copy_rates_from",
|
||||
"copy_rates_from_pos",
|
||||
"copy_rates_range",
|
||||
"copy_ticks_from",
|
||||
"copy_ticks_range",
|
||||
"history_deals",
|
||||
"history_orders",
|
||||
"last_error",
|
||||
"market_book",
|
||||
"orders",
|
||||
"positions",
|
||||
"symbol_info",
|
||||
"symbol_info_tick",
|
||||
"symbols",
|
||||
"terminal_info",
|
||||
"update_history",
|
||||
"update_history_with_config",
|
||||
"version",
|
||||
]
|
||||
|
||||
|
||||
def _coerce_timeframe(timeframe: int | str) -> int:
|
||||
if isinstance(timeframe, int):
|
||||
return timeframe
|
||||
return parse_timeframe(timeframe)
|
||||
|
||||
|
||||
def _coerce_tick_flags(flags: int | str) -> int:
|
||||
if isinstance(flags, int):
|
||||
return flags
|
||||
return parse_tick_flags(flags)
|
||||
|
||||
|
||||
def _require_datetime(value: datetime | str) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return parse_datetime(value)
|
||||
|
||||
|
||||
def _coerce_datetime(value: datetime | str | None) -> datetime | None:
|
||||
if value is None or isinstance(value, datetime):
|
||||
return value
|
||||
return parse_datetime(value)
|
||||
|
||||
|
||||
def build_config(
|
||||
*,
|
||||
path: str | None = None,
|
||||
login: int | None = None,
|
||||
password: str | None = None,
|
||||
server: str | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> Mt5Config:
|
||||
"""Build an ``Mt5Config`` from optional connection parameters.
|
||||
|
||||
Returns:
|
||||
Configured ``Mt5Config`` instance.
|
||||
"""
|
||||
return Mt5Config(
|
||||
path=path,
|
||||
login=login,
|
||||
password=password,
|
||||
server=server,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _connected_client(config: Mt5Config) -> Iterator[Mt5DataClient]:
|
||||
"""Initialize MT5, yield a connected client, and always shut down.
|
||||
|
||||
Args:
|
||||
config: MT5 connection configuration.
|
||||
|
||||
Yields:
|
||||
Connected ``Mt5DataClient`` instance.
|
||||
"""
|
||||
client = Mt5DataClient(config=config)
|
||||
try:
|
||||
client.initialize_and_login_mt5()
|
||||
yield client
|
||||
finally:
|
||||
client.shutdown()
|
||||
|
||||
|
||||
def _run_with_client(
|
||||
config: Mt5Config,
|
||||
fetch_fn: Callable[[Mt5DataClient], T],
|
||||
) -> T:
|
||||
"""Connect, run ``fetch_fn``, and shut down safely.
|
||||
|
||||
Args:
|
||||
config: MT5 connection configuration.
|
||||
fetch_fn: Callable receiving a connected client.
|
||||
|
||||
Returns:
|
||||
Value returned by ``fetch_fn``.
|
||||
"""
|
||||
with _connected_client(config) as client:
|
||||
return fetch_fn(client)
|
||||
|
||||
|
||||
class Mt5CliClient:
|
||||
"""Programmatic client for read-only MetaTrader 5 data access."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
path: str | None = None,
|
||||
login: int | None = None,
|
||||
password: str | None = None,
|
||||
server: str | None = None,
|
||||
timeout: int | None = None,
|
||||
config: Mt5Config | None = None,
|
||||
) -> None:
|
||||
"""Initialize the SDK client.
|
||||
|
||||
Args:
|
||||
path: Path to MetaTrader5 terminal EXE file.
|
||||
login: Trading account login.
|
||||
password: Trading account password.
|
||||
server: Trading server name.
|
||||
timeout: Connection timeout in milliseconds.
|
||||
config: Optional pre-built ``Mt5Config`` (overrides other args).
|
||||
"""
|
||||
self._config = config or build_config(
|
||||
path=path,
|
||||
login=login,
|
||||
password=password,
|
||||
server=server,
|
||||
timeout=timeout,
|
||||
)
|
||||
self._client: Mt5DataClient | None = None
|
||||
|
||||
@property
|
||||
def config(self) -> Mt5Config:
|
||||
"""Return the underlying MT5 configuration."""
|
||||
return self._config
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
"""Open a persistent MT5 connection for multiple calls.
|
||||
|
||||
Returns:
|
||||
This client instance.
|
||||
"""
|
||||
client = Mt5DataClient(config=self._config)
|
||||
try:
|
||||
client.initialize_and_login_mt5()
|
||||
except Exception:
|
||||
client.shutdown()
|
||||
raise
|
||||
self._client = client
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: object,
|
||||
) -> None:
|
||||
"""Shut down the persistent MT5 connection."""
|
||||
if self._client is not None:
|
||||
self._client.shutdown()
|
||||
self._client = None
|
||||
|
||||
def _fetch(self, fetch_fn: Callable[[Mt5DataClient], pd.DataFrame]) -> pd.DataFrame:
|
||||
if self._client is not None:
|
||||
return fetch_fn(self._client)
|
||||
return _run_with_client(self._config, fetch_fn)
|
||||
|
||||
def copy_rates_from(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: int | str,
|
||||
date_from: datetime | str,
|
||||
count: int,
|
||||
) -> pd.DataFrame:
|
||||
"""Return rates starting from a date."""
|
||||
tf = _coerce_timeframe(timeframe)
|
||||
start = _require_datetime(date_from)
|
||||
return self._fetch(
|
||||
lambda c: c.copy_rates_from_as_df(
|
||||
symbol=symbol,
|
||||
timeframe=tf,
|
||||
date_from=start,
|
||||
count=count,
|
||||
),
|
||||
)
|
||||
|
||||
def copy_rates_from_pos(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: int | str,
|
||||
start_pos: int,
|
||||
count: int,
|
||||
) -> pd.DataFrame:
|
||||
"""Return rates starting from a bar position."""
|
||||
tf = _coerce_timeframe(timeframe)
|
||||
return self._fetch(
|
||||
lambda c: c.copy_rates_from_pos_as_df(
|
||||
symbol=symbol,
|
||||
timeframe=tf,
|
||||
start_pos=start_pos,
|
||||
count=count,
|
||||
),
|
||||
)
|
||||
|
||||
def copy_rates_range(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: int | str,
|
||||
date_from: datetime | str,
|
||||
date_to: datetime | str,
|
||||
) -> pd.DataFrame:
|
||||
"""Return rates for a date range."""
|
||||
tf = _coerce_timeframe(timeframe)
|
||||
start = _require_datetime(date_from)
|
||||
end = _require_datetime(date_to)
|
||||
return self._fetch(
|
||||
lambda c: c.copy_rates_range_as_df(
|
||||
symbol=symbol,
|
||||
timeframe=tf,
|
||||
date_from=start,
|
||||
date_to=end,
|
||||
),
|
||||
)
|
||||
|
||||
def copy_ticks_from(
|
||||
self,
|
||||
symbol: str,
|
||||
date_from: datetime | str,
|
||||
count: int,
|
||||
flags: int | str,
|
||||
) -> pd.DataFrame:
|
||||
"""Return ticks starting from a date."""
|
||||
start = _require_datetime(date_from)
|
||||
tick_flags = _coerce_tick_flags(flags)
|
||||
return self._fetch(
|
||||
lambda c: c.copy_ticks_from_as_df(
|
||||
symbol=symbol,
|
||||
date_from=start,
|
||||
count=count,
|
||||
flags=tick_flags,
|
||||
),
|
||||
)
|
||||
|
||||
def copy_ticks_range(
|
||||
self,
|
||||
symbol: str,
|
||||
date_from: datetime | str,
|
||||
date_to: datetime | str,
|
||||
flags: int | str,
|
||||
) -> pd.DataFrame:
|
||||
"""Return ticks for a date range."""
|
||||
start = _require_datetime(date_from)
|
||||
end = _require_datetime(date_to)
|
||||
tick_flags = _coerce_tick_flags(flags)
|
||||
return self._fetch(
|
||||
lambda c: c.copy_ticks_range_as_df(
|
||||
symbol=symbol,
|
||||
date_from=start,
|
||||
date_to=end,
|
||||
flags=tick_flags,
|
||||
),
|
||||
)
|
||||
|
||||
def account_info(self) -> pd.DataFrame:
|
||||
"""Return account information."""
|
||||
return self._fetch(lambda c: c.account_info_as_df())
|
||||
|
||||
def terminal_info(self) -> pd.DataFrame:
|
||||
"""Return terminal information."""
|
||||
return self._fetch(lambda c: c.terminal_info_as_df())
|
||||
|
||||
def symbols(self, group: str | None = None) -> pd.DataFrame:
|
||||
"""Return the symbol list."""
|
||||
return self._fetch(lambda c: c.symbols_get_as_df(group=group))
|
||||
|
||||
def symbol_info(self, symbol: str) -> pd.DataFrame:
|
||||
"""Return details for one symbol."""
|
||||
return self._fetch(lambda c: c.symbol_info_as_df(symbol=symbol))
|
||||
|
||||
def orders(
|
||||
self,
|
||||
symbol: str | None = None,
|
||||
group: str | None = None,
|
||||
ticket: int | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return active orders."""
|
||||
return self._fetch(
|
||||
lambda c: c.orders_get_as_df(
|
||||
symbol=symbol,
|
||||
group=group,
|
||||
ticket=ticket,
|
||||
),
|
||||
)
|
||||
|
||||
def positions(
|
||||
self,
|
||||
symbol: str | None = None,
|
||||
group: str | None = None,
|
||||
ticket: int | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return open positions."""
|
||||
return self._fetch(
|
||||
lambda c: c.positions_get_as_df(
|
||||
symbol=symbol,
|
||||
group=group,
|
||||
ticket=ticket,
|
||||
),
|
||||
)
|
||||
|
||||
def history_orders(
|
||||
self,
|
||||
date_from: datetime | str | None = None,
|
||||
date_to: datetime | str | None = None,
|
||||
group: str | None = None,
|
||||
symbol: str | None = None,
|
||||
ticket: int | None = None,
|
||||
position: int | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return historical orders."""
|
||||
start = _coerce_datetime(date_from)
|
||||
end = _coerce_datetime(date_to)
|
||||
return self._fetch(
|
||||
lambda c: c.history_orders_get_as_df(
|
||||
date_from=start,
|
||||
date_to=end,
|
||||
group=group,
|
||||
symbol=symbol,
|
||||
ticket=ticket,
|
||||
position=position,
|
||||
),
|
||||
)
|
||||
|
||||
def history_deals(
|
||||
self,
|
||||
date_from: datetime | str | None = None,
|
||||
date_to: datetime | str | None = None,
|
||||
group: str | None = None,
|
||||
symbol: str | None = None,
|
||||
ticket: int | None = None,
|
||||
position: int | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return historical deals."""
|
||||
start = _coerce_datetime(date_from)
|
||||
end = _coerce_datetime(date_to)
|
||||
return self._fetch(
|
||||
lambda c: c.history_deals_get_as_df(
|
||||
date_from=start,
|
||||
date_to=end,
|
||||
group=group,
|
||||
symbol=symbol,
|
||||
ticket=ticket,
|
||||
position=position,
|
||||
),
|
||||
)
|
||||
|
||||
def version(self) -> pd.DataFrame:
|
||||
"""Return MetaTrader5 version information."""
|
||||
return self._fetch(lambda c: c.version_as_df())
|
||||
|
||||
def last_error(self) -> pd.DataFrame:
|
||||
"""Return the last error information."""
|
||||
return self._fetch(lambda c: c.last_error_as_df())
|
||||
|
||||
def symbol_info_tick(self, symbol: str) -> pd.DataFrame:
|
||||
"""Return the last tick for a symbol."""
|
||||
return self._fetch(lambda c: c.symbol_info_tick_as_df(symbol=symbol))
|
||||
|
||||
def market_book(self, symbol: str) -> pd.DataFrame:
|
||||
"""Return market depth for a symbol."""
|
||||
return self._fetch(lambda c: c.market_book_get_as_df(symbol=symbol))
|
||||
|
||||
|
||||
def _resolve_incremental_settings(
|
||||
selected_datasets: set[Dataset],
|
||||
timeframes: Sequence[int | str] | None,
|
||||
flags: int | str,
|
||||
) -> tuple[list[int], int]:
|
||||
"""Resolve dataset-specific incremental update settings.
|
||||
|
||||
Returns:
|
||||
Tuple of resolved rate timeframes and tick copy flags.
|
||||
|
||||
Raises:
|
||||
ValueError: If timeframe or tick flag values are invalid.
|
||||
"""
|
||||
resolved_timeframes: list[int] = []
|
||||
if Dataset.rates in selected_datasets:
|
||||
try:
|
||||
resolved_timeframes = resolve_history_timeframes(timeframes)
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise ValueError(msg) from exc
|
||||
resolved_tick_flags = 0
|
||||
if Dataset.ticks in selected_datasets:
|
||||
try:
|
||||
resolved_tick_flags = resolve_history_tick_flags(flags)
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise ValueError(msg) from exc
|
||||
return resolved_timeframes, resolved_tick_flags
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _UpdateHistoryRequest:
|
||||
selected: set[Dataset]
|
||||
end: datetime
|
||||
fallback_start: datetime
|
||||
resolved_timeframes: list[int]
|
||||
resolved_tick_flags: int
|
||||
output_path: Path
|
||||
|
||||
|
||||
def _resolve_update_history_request(
|
||||
*,
|
||||
output: Path | str,
|
||||
symbols: Sequence[str],
|
||||
datasets: set[Dataset] | None,
|
||||
timeframes: Sequence[int | str] | None,
|
||||
flags: int | str,
|
||||
lookback_hours: float,
|
||||
date_to: datetime | str | None,
|
||||
) -> _UpdateHistoryRequest | None:
|
||||
"""Validate and resolve incremental history update inputs.
|
||||
|
||||
Returns:
|
||||
Resolved request parameters, or None when no datasets are selected.
|
||||
|
||||
Raises:
|
||||
ValueError: If symbols are empty, lookback_hours is not positive, or
|
||||
timeframe/flag values are invalid.
|
||||
"""
|
||||
if lookback_hours <= 0:
|
||||
msg = "lookback_hours must be positive."
|
||||
raise ValueError(msg)
|
||||
selected = resolve_history_datasets(datasets)
|
||||
if not selected:
|
||||
logger.info("Skipping SQLite history update: no datasets selected.")
|
||||
return None
|
||||
if not symbols:
|
||||
msg = "At least one symbol is required."
|
||||
raise ValueError(msg)
|
||||
|
||||
if date_to is not None:
|
||||
resolved_end = _coerce_datetime(date_to)
|
||||
else:
|
||||
resolved_end = datetime.now(UTC)
|
||||
end = resolved_end if resolved_end is not None else datetime.now(UTC)
|
||||
fallback_start = end - timedelta(hours=lookback_hours)
|
||||
resolved_timeframes, resolved_tick_flags = _resolve_incremental_settings(
|
||||
selected,
|
||||
timeframes,
|
||||
flags,
|
||||
)
|
||||
return _UpdateHistoryRequest(
|
||||
selected=selected,
|
||||
end=end,
|
||||
fallback_start=fallback_start,
|
||||
resolved_timeframes=resolved_timeframes,
|
||||
resolved_tick_flags=resolved_tick_flags,
|
||||
output_path=Path(output),
|
||||
)
|
||||
|
||||
|
||||
def update_history( # noqa: PLR0913
|
||||
*,
|
||||
client: Mt5DataClient,
|
||||
output: Path | str,
|
||||
symbols: Sequence[str],
|
||||
datasets: set[Dataset] | None = None,
|
||||
timeframes: Sequence[int | str] | None = None,
|
||||
flags: int | str = "ALL",
|
||||
lookback_hours: float = 24.0,
|
||||
date_to: datetime | str | None = None,
|
||||
deduplicate: bool = True,
|
||||
create_rate_views: bool = True,
|
||||
with_views: bool = False,
|
||||
include_account_events: bool = True,
|
||||
) -> None:
|
||||
"""Incrementally append MT5 history into a SQLite database.
|
||||
|
||||
Uses an already-connected ``Mt5DataClient`` and does not create or close
|
||||
the MT5 connection. For first-time tables, data is fetched from
|
||||
``date_to - lookback_hours``. Subsequent runs resume from existing
|
||||
``MAX(time)`` per symbol (and timeframe for rates); when
|
||||
``include_account_events=True``, account-level deals use a separate cursor
|
||||
over ``type NOT IN (0, 1)`` / empty-symbol rows.
|
||||
|
||||
Args:
|
||||
client: Connected MT5 data client.
|
||||
output: SQLite database path.
|
||||
symbols: Symbols to update.
|
||||
datasets: Datasets to include (defaults to all).
|
||||
timeframes: Rate timeframes to update (defaults to all fixed MT5
|
||||
timeframes when None).
|
||||
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
||||
lookback_hours: First-run lookback when a table has no prior rows.
|
||||
date_to: Optional update end datetime. Defaults to now (UTC).
|
||||
deduplicate: Remove duplicate rows after append, keeping latest ROWID.
|
||||
create_rate_views: Create ``rate_<symbol>__<timeframe>`` views.
|
||||
with_views: Create ``cash_events`` and ``positions_reconstructed`` views.
|
||||
include_account_events: Include account-level cash events in
|
||||
``history_deals`` when True.
|
||||
"""
|
||||
request = _resolve_update_history_request(
|
||||
output=output,
|
||||
symbols=symbols,
|
||||
datasets=datasets,
|
||||
timeframes=timeframes,
|
||||
flags=flags,
|
||||
lookback_hours=lookback_hours,
|
||||
date_to=date_to,
|
||||
)
|
||||
if request is None:
|
||||
return
|
||||
logger.info(
|
||||
"Updating history in SQLite: symbols=%s, datasets=%s, path=%s",
|
||||
list(symbols),
|
||||
sorted(dataset.value for dataset in request.selected),
|
||||
request.output_path,
|
||||
)
|
||||
with sqlite3.connect(request.output_path) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
write_incremental_datasets(
|
||||
conn,
|
||||
client,
|
||||
symbols,
|
||||
request.selected,
|
||||
request.resolved_timeframes,
|
||||
request.resolved_tick_flags,
|
||||
request.fallback_start,
|
||||
request.end,
|
||||
deduplicate=deduplicate,
|
||||
create_rate_views=create_rate_views,
|
||||
with_views=with_views,
|
||||
include_account_events=include_account_events,
|
||||
)
|
||||
|
||||
|
||||
def update_history_with_config( # noqa: PLR0913
|
||||
*,
|
||||
output: Path | str,
|
||||
symbols: Sequence[str],
|
||||
config: Mt5Config | None = None,
|
||||
datasets: set[Dataset] | None = None,
|
||||
timeframes: Sequence[int | str] | None = None,
|
||||
flags: int | str = "ALL",
|
||||
lookback_hours: float = 24.0,
|
||||
date_to: datetime | str | None = None,
|
||||
deduplicate: bool = True,
|
||||
create_rate_views: bool = True,
|
||||
with_views: bool = False,
|
||||
include_account_events: bool = True,
|
||||
) -> None:
|
||||
"""Incrementally append MT5 history, opening and closing the MT5 connection.
|
||||
|
||||
Convenience wrapper around :func:`update_history` for standalone use.
|
||||
"""
|
||||
request = _resolve_update_history_request(
|
||||
output=output,
|
||||
symbols=symbols,
|
||||
datasets=datasets,
|
||||
timeframes=timeframes,
|
||||
flags=flags,
|
||||
lookback_hours=lookback_hours,
|
||||
date_to=date_to,
|
||||
)
|
||||
if request is None:
|
||||
return
|
||||
mt5_config = config or build_config()
|
||||
with _connected_client(mt5_config) as client:
|
||||
update_history(
|
||||
client=client,
|
||||
output=output,
|
||||
symbols=symbols,
|
||||
datasets=datasets,
|
||||
timeframes=timeframes,
|
||||
flags=flags,
|
||||
lookback_hours=lookback_hours,
|
||||
date_to=date_to,
|
||||
deduplicate=deduplicate,
|
||||
create_rate_views=create_rate_views,
|
||||
with_views=with_views,
|
||||
include_account_events=include_account_events,
|
||||
)
|
||||
|
||||
|
||||
def collect_history(
|
||||
output: Path,
|
||||
symbols: list[str],
|
||||
date_from: datetime | str,
|
||||
date_to: datetime | str,
|
||||
*,
|
||||
datasets: set[Dataset] | None = None,
|
||||
timeframe: int | str = 1,
|
||||
flags: int | str = 1,
|
||||
if_exists: IfExists = IfExists.FAIL,
|
||||
with_views: bool = False,
|
||||
config: Mt5Config | None = None,
|
||||
) -> None:
|
||||
"""Collect historical datasets into a single SQLite database.
|
||||
|
||||
Args:
|
||||
output: SQLite database path.
|
||||
symbols: Symbols to collect.
|
||||
date_from: Start date.
|
||||
date_to: End date.
|
||||
datasets: Datasets to include (defaults to all).
|
||||
timeframe: Rates timeframe as integer or name (e.g. ``M1``).
|
||||
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
||||
if_exists: Behavior when a target table already exists.
|
||||
with_views: Create ``cash_events`` and ``positions_reconstructed`` views.
|
||||
config: MT5 connection configuration.
|
||||
"""
|
||||
start = _require_datetime(date_from)
|
||||
end = _require_datetime(date_to)
|
||||
selected = datasets if datasets is not None else set(Dataset)
|
||||
tf = _coerce_timeframe(timeframe)
|
||||
tick_flags = _coerce_tick_flags(flags)
|
||||
mt5_config = config or build_config()
|
||||
with _connected_client(mt5_config) as client, sqlite3.connect(output) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
written_tables, written_columns = write_collected_datasets(
|
||||
conn,
|
||||
client,
|
||||
symbols,
|
||||
selected,
|
||||
tf,
|
||||
tick_flags,
|
||||
start,
|
||||
end,
|
||||
if_exists,
|
||||
)
|
||||
create_history_indexes(conn, written_columns)
|
||||
if with_views and Dataset.history_deals in written_tables:
|
||||
create_cash_events_view(conn, written_columns[Dataset.history_deals])
|
||||
create_positions_reconstructed_view(
|
||||
conn,
|
||||
written_columns[Dataset.history_deals],
|
||||
)
|
||||
elif with_views:
|
||||
logger.warning(
|
||||
"--with-views ignored: history_deals table was not written",
|
||||
)
|
||||
logger.info(
|
||||
"Collected %s for %d symbol(s) into %s",
|
||||
", ".join(sorted(ds.value for ds in selected)),
|
||||
len(symbols),
|
||||
output,
|
||||
)
|
||||
|
||||
|
||||
def _make_client(*, config: Mt5Config | None = None) -> Mt5CliClient:
|
||||
return Mt5CliClient(config=config) if config is not None else Mt5CliClient()
|
||||
|
||||
|
||||
def copy_rates_from(
|
||||
symbol: str,
|
||||
timeframe: int | str,
|
||||
date_from: datetime | str,
|
||||
count: int,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return rates starting from a date."""
|
||||
return _make_client(config=config).copy_rates_from(
|
||||
symbol,
|
||||
timeframe,
|
||||
date_from,
|
||||
count,
|
||||
)
|
||||
|
||||
|
||||
def copy_rates_from_pos(
|
||||
symbol: str,
|
||||
timeframe: int | str,
|
||||
start_pos: int,
|
||||
count: int,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return rates starting from a bar position."""
|
||||
return _make_client(config=config).copy_rates_from_pos(
|
||||
symbol,
|
||||
timeframe,
|
||||
start_pos,
|
||||
count,
|
||||
)
|
||||
|
||||
|
||||
def copy_rates_range(
|
||||
symbol: str,
|
||||
timeframe: int | str,
|
||||
date_from: datetime | str,
|
||||
date_to: datetime | str,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return rates for a date range."""
|
||||
return _make_client(config=config).copy_rates_range(
|
||||
symbol,
|
||||
timeframe,
|
||||
date_from,
|
||||
date_to,
|
||||
)
|
||||
|
||||
|
||||
def copy_ticks_from(
|
||||
symbol: str,
|
||||
date_from: datetime | str,
|
||||
count: int,
|
||||
flags: int | str,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return ticks starting from a date."""
|
||||
return _make_client(config=config).copy_ticks_from(
|
||||
symbol,
|
||||
date_from,
|
||||
count,
|
||||
flags,
|
||||
)
|
||||
|
||||
|
||||
def copy_ticks_range(
|
||||
symbol: str,
|
||||
date_from: datetime | str,
|
||||
date_to: datetime | str,
|
||||
flags: int | str,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return ticks for a date range."""
|
||||
return _make_client(config=config).copy_ticks_range(
|
||||
symbol,
|
||||
date_from,
|
||||
date_to,
|
||||
flags,
|
||||
)
|
||||
|
||||
|
||||
def account_info(*, config: Mt5Config | None = None) -> pd.DataFrame:
|
||||
"""Return account information."""
|
||||
return _make_client(config=config).account_info()
|
||||
|
||||
|
||||
def terminal_info(*, config: Mt5Config | None = None) -> pd.DataFrame:
|
||||
"""Return terminal information."""
|
||||
return _make_client(config=config).terminal_info()
|
||||
|
||||
|
||||
def symbols(
|
||||
group: str | None = None,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return the symbol list."""
|
||||
return _make_client(config=config).symbols(group=group)
|
||||
|
||||
|
||||
def symbol_info(
|
||||
symbol: str,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return details for one symbol."""
|
||||
return _make_client(config=config).symbol_info(symbol)
|
||||
|
||||
|
||||
def orders(
|
||||
symbol: str | None = None,
|
||||
group: str | None = None,
|
||||
ticket: int | None = None,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return active orders."""
|
||||
return _make_client(config=config).orders(
|
||||
symbol=symbol,
|
||||
group=group,
|
||||
ticket=ticket,
|
||||
)
|
||||
|
||||
|
||||
def positions(
|
||||
symbol: str | None = None,
|
||||
group: str | None = None,
|
||||
ticket: int | None = None,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return open positions."""
|
||||
return _make_client(config=config).positions(
|
||||
symbol=symbol,
|
||||
group=group,
|
||||
ticket=ticket,
|
||||
)
|
||||
|
||||
|
||||
def history_orders(
|
||||
date_from: datetime | str | None = None,
|
||||
date_to: datetime | str | None = None,
|
||||
group: str | None = None,
|
||||
symbol: str | None = None,
|
||||
ticket: int | None = None,
|
||||
position: int | None = None,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return historical orders."""
|
||||
return _make_client(config=config).history_orders(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
group=group,
|
||||
symbol=symbol,
|
||||
ticket=ticket,
|
||||
position=position,
|
||||
)
|
||||
|
||||
|
||||
def history_deals(
|
||||
date_from: datetime | str | None = None,
|
||||
date_to: datetime | str | None = None,
|
||||
group: str | None = None,
|
||||
symbol: str | None = None,
|
||||
ticket: int | None = None,
|
||||
position: int | None = None,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return historical deals."""
|
||||
return _make_client(config=config).history_deals(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
group=group,
|
||||
symbol=symbol,
|
||||
ticket=ticket,
|
||||
position=position,
|
||||
)
|
||||
|
||||
|
||||
def version(*, config: Mt5Config | None = None) -> pd.DataFrame:
|
||||
"""Return MetaTrader5 version information."""
|
||||
return _make_client(config=config).version()
|
||||
|
||||
|
||||
def last_error(*, config: Mt5Config | None = None) -> pd.DataFrame:
|
||||
"""Return the last error information."""
|
||||
return _make_client(config=config).last_error()
|
||||
|
||||
|
||||
def symbol_info_tick(
|
||||
symbol: str,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return the last tick for a symbol."""
|
||||
return _make_client(config=config).symbol_info_tick(symbol)
|
||||
|
||||
|
||||
def market_book(
|
||||
symbol: str,
|
||||
*,
|
||||
config: Mt5Config | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Return market depth for a symbol."""
|
||||
return _make_client(config=config).market_book(symbol)
|
||||
File diff suppressed because it is too large
Load Diff
+408
@@ -0,0 +1,408 @@
|
||||
"""Utility constants, types, and functions for the mt5cli package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TypeGuard, cast
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pandas as pd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TIMEFRAME_MAP: dict[str, int] = {
|
||||
"M1": 1,
|
||||
"M2": 2,
|
||||
"M3": 3,
|
||||
"M4": 4,
|
||||
"M5": 5,
|
||||
"M6": 6,
|
||||
"M10": 10,
|
||||
"M12": 12,
|
||||
"M15": 15,
|
||||
"M20": 20,
|
||||
"M30": 30,
|
||||
"H1": 16385,
|
||||
"H2": 16386,
|
||||
"H3": 16387,
|
||||
"H4": 16388,
|
||||
"H6": 16390,
|
||||
"H8": 16392,
|
||||
"H12": 16396,
|
||||
"D1": 16408,
|
||||
"W1": 32769,
|
||||
"MN1": 49153,
|
||||
}
|
||||
|
||||
TICK_FLAG_MAP: dict[str, int] = {
|
||||
"ALL": 1,
|
||||
"INFO": 2,
|
||||
"TRADE": 4,
|
||||
}
|
||||
|
||||
_FORMAT_EXTENSIONS: dict[str, str] = {
|
||||
".csv": "csv",
|
||||
".json": "json",
|
||||
".parquet": "parquet",
|
||||
".pq": "parquet",
|
||||
".db": "sqlite3",
|
||||
".sqlite": "sqlite3",
|
||||
".sqlite3": "sqlite3",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OutputFormat(StrEnum):
|
||||
"""Supported output file formats."""
|
||||
|
||||
csv = "csv"
|
||||
json = "json"
|
||||
parquet = "parquet"
|
||||
sqlite3 = "sqlite3"
|
||||
|
||||
|
||||
class LogLevel(StrEnum):
|
||||
"""Logging verbosity levels."""
|
||||
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
|
||||
|
||||
class Dataset(StrEnum):
|
||||
"""Datasets supported by the ``collect-history`` command."""
|
||||
|
||||
rates = "rates"
|
||||
ticks = "ticks"
|
||||
history_orders = "history-orders"
|
||||
history_deals = "history-deals"
|
||||
|
||||
@property
|
||||
def table_name(self) -> str:
|
||||
"""Return the SQLite table name for this dataset."""
|
||||
return self.value.replace("-", "_")
|
||||
|
||||
|
||||
class IfExists(StrEnum):
|
||||
"""SQLite table conflict behavior for the ``collect-history`` command."""
|
||||
|
||||
APPEND = "append"
|
||||
REPLACE = "replace"
|
||||
FAIL = "fail"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Click parameter types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DateTimeType(click.ParamType):
|
||||
"""Click parameter type for ISO 8601 datetime strings."""
|
||||
|
||||
name = "DATETIME"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> datetime:
|
||||
"""Convert a string value to a timezone-aware datetime.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Parsed datetime.
|
||||
"""
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
try:
|
||||
return parse_datetime(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
class _TimeframeType(click.ParamType):
|
||||
"""Click parameter type for MT5 timeframe values."""
|
||||
|
||||
name = "TIMEFRAME"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> int:
|
||||
"""Convert a string or integer value to a timeframe integer.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Integer timeframe value.
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
try:
|
||||
return parse_timeframe(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
class _TickFlagsType(click.ParamType):
|
||||
"""Click parameter type for MT5 tick copy flags."""
|
||||
|
||||
name = "FLAGS"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> int:
|
||||
"""Convert a string or integer value to a tick flags integer.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Integer tick flag value.
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
try:
|
||||
return parse_tick_flags(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
class _RequestType(click.ParamType):
|
||||
"""Click parameter type for JSON order requests."""
|
||||
|
||||
name = "REQUEST"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert a raw CLI value to an order request dictionary.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Parsed request dictionary.
|
||||
"""
|
||||
try:
|
||||
return parse_request(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
DATETIME_TYPE = _DateTimeType()
|
||||
TIMEFRAME_TYPE = _TimeframeType()
|
||||
TICK_FLAGS_TYPE = _TickFlagsType()
|
||||
REQUEST_TYPE = _RequestType()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public utility functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def detect_format(
|
||||
output_path: Path,
|
||||
explicit_format: str | None = None,
|
||||
) -> str:
|
||||
"""Detect the output format from a file extension or explicit format string.
|
||||
|
||||
Args:
|
||||
output_path: Path to the output file.
|
||||
explicit_format: Explicitly specified format, if any.
|
||||
|
||||
Returns:
|
||||
The detected format string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the format cannot be determined.
|
||||
"""
|
||||
if explicit_format is not None:
|
||||
return explicit_format
|
||||
suffix = output_path.suffix.lower()
|
||||
if suffix in _FORMAT_EXTENSIONS:
|
||||
return _FORMAT_EXTENSIONS[suffix]
|
||||
msg = (
|
||||
f"Cannot detect format from extension '{suffix}'."
|
||||
" Use --format to specify the output format."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def export_dataframe(
|
||||
df: pd.DataFrame,
|
||||
output_path: Path,
|
||||
output_format: str,
|
||||
table_name: str = "data",
|
||||
) -> None:
|
||||
"""Export a pandas DataFrame to the specified file format.
|
||||
|
||||
Args:
|
||||
df: DataFrame to export.
|
||||
output_path: Path to the output file.
|
||||
output_format: Output format (csv, json, parquet, or sqlite3).
|
||||
table_name: Table name for SQLite3 output.
|
||||
|
||||
Raises:
|
||||
ValueError: If the output format is not supported.
|
||||
"""
|
||||
if output_format == "csv":
|
||||
df.to_csv(output_path, index=False)
|
||||
elif output_format == "json":
|
||||
df.to_json(
|
||||
output_path,
|
||||
orient="records",
|
||||
date_format="iso",
|
||||
indent=2,
|
||||
)
|
||||
elif output_format == "parquet":
|
||||
df.to_parquet(output_path, index=False)
|
||||
elif output_format == "sqlite3":
|
||||
sqlite3 = cast("Any", importlib.import_module("sqlite3"))
|
||||
with sqlite3.connect(output_path) as conn:
|
||||
df.to_sql( # type: ignore[reportUnknownMemberType]
|
||||
table_name,
|
||||
conn,
|
||||
if_exists="replace",
|
||||
index=False,
|
||||
)
|
||||
else:
|
||||
msg = f"Unsupported output format: {output_format}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def parse_datetime(value: str) -> datetime:
|
||||
"""Parse an ISO 8601 datetime string to a timezone-aware datetime.
|
||||
|
||||
Args:
|
||||
value: ISO 8601 datetime string (e.g., '2024-01-01' or
|
||||
'2024-01-01T12:00:00+00:00').
|
||||
|
||||
Returns:
|
||||
Parsed datetime with UTC timezone if no timezone is specified.
|
||||
|
||||
Raises:
|
||||
ValueError: If the string cannot be parsed.
|
||||
"""
|
||||
try:
|
||||
dt = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
msg = f"Invalid datetime format: '{value}'. Use ISO 8601 format."
|
||||
raise ValueError(msg) from None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
def parse_timeframe(value: str) -> int:
|
||||
"""Parse a timeframe string or integer value.
|
||||
|
||||
Args:
|
||||
value: Timeframe name (e.g., 'M1', 'H1', 'D1') or integer value.
|
||||
|
||||
Returns:
|
||||
Integer timeframe value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the timeframe is invalid.
|
||||
"""
|
||||
upper = value.upper()
|
||||
if upper in TIMEFRAME_MAP:
|
||||
return TIMEFRAME_MAP[upper]
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
valid = ", ".join(TIMEFRAME_MAP)
|
||||
msg = f"Invalid timeframe: '{value}'. Use one of: {valid}, or an integer."
|
||||
raise ValueError(msg) from None
|
||||
|
||||
|
||||
def parse_tick_flags(value: str) -> int:
|
||||
"""Parse tick flags string or integer value.
|
||||
|
||||
Args:
|
||||
value: Tick flag name (ALL, INFO, TRADE) or integer value.
|
||||
|
||||
Returns:
|
||||
Integer tick flag value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the flag is invalid.
|
||||
"""
|
||||
upper = value.upper()
|
||||
if upper in TICK_FLAG_MAP:
|
||||
return TICK_FLAG_MAP[upper]
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
valid = ", ".join(TICK_FLAG_MAP)
|
||||
msg = f"Invalid tick flags: '{value}'. Use one of: {valid}, or an integer."
|
||||
raise ValueError(msg) from None
|
||||
|
||||
|
||||
def _is_request_dict(value: object) -> TypeGuard[dict[str, Any]]:
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def parse_request(value: str) -> dict[str, Any]:
|
||||
"""Parse a JSON-formatted order request string or file reference.
|
||||
|
||||
Args:
|
||||
value: JSON object string, or '@path' to read JSON from a file.
|
||||
|
||||
Returns:
|
||||
Parsed request dictionary.
|
||||
|
||||
Raises:
|
||||
ValueError: If the request file cannot be read or the value is not a
|
||||
JSON object.
|
||||
"""
|
||||
if value.startswith("@"):
|
||||
path = Path(value[1:])
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
msg = f"Failed to read JSON request file '{path}': {exc}"
|
||||
raise ValueError(msg) from exc
|
||||
else:
|
||||
text = value
|
||||
try:
|
||||
parsed: object = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
msg = f"Invalid JSON request: {exc}"
|
||||
raise ValueError(msg) from exc
|
||||
if not _is_request_dict(parsed):
|
||||
msg = "Order request must be a JSON object."
|
||||
raise ValueError(msg)
|
||||
return parsed
|
||||
+2
-5
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "0.3.0"
|
||||
version = "0.4.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"}]
|
||||
@@ -48,10 +48,6 @@ dev = [
|
||||
"pymdown-extensions >= 10.21.2",
|
||||
]
|
||||
|
||||
[tool.uv.build-backend]
|
||||
source-include = ["mt5cli/**", "LICENSE"]
|
||||
source-exclude = ["tests/**"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
exclude = ["build", ".venv"]
|
||||
@@ -128,6 +124,7 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"mt5cli/sqlite_history.py" = ["TC003"]
|
||||
"tests/**/*.py" = [
|
||||
"DOC201", # Missing return documentation
|
||||
"DOC501", # Raised exception missing from docstring
|
||||
|
||||
+15
-319
@@ -19,22 +19,11 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli.cli import (
|
||||
DATETIME_TYPE,
|
||||
REQUEST_TYPE,
|
||||
TICK_FLAG_MAP,
|
||||
TICK_FLAGS_TYPE,
|
||||
TIMEFRAME_MAP,
|
||||
TIMEFRAME_TYPE,
|
||||
_execute_export, # type: ignore[reportPrivateUsage]
|
||||
_ExportContext, # type: ignore[reportPrivateUsage]
|
||||
_sdk_client, # type: ignore[reportPrivateUsage]
|
||||
app,
|
||||
detect_format,
|
||||
export_dataframe,
|
||||
main,
|
||||
parse_datetime,
|
||||
parse_request,
|
||||
parse_tick_flags,
|
||||
parse_timeframe,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -46,299 +35,6 @@ def normalize_cli_output(output: str) -> str:
|
||||
return " ".join(_ANSI_ESCAPE_RE.sub("", output).split())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectFormat:
|
||||
"""Tests for detect_format."""
|
||||
|
||||
def test_explicit_format_returned(self, tmp_path: Path) -> None:
|
||||
"""Test that explicit format overrides extension."""
|
||||
result = detect_format(tmp_path / "data.txt", explicit_format="csv")
|
||||
assert result == "csv"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "expected"),
|
||||
[
|
||||
("data.csv", "csv"),
|
||||
("data.json", "json"),
|
||||
("data.parquet", "parquet"),
|
||||
("data.pq", "parquet"),
|
||||
("data.db", "sqlite3"),
|
||||
("data.sqlite", "sqlite3"),
|
||||
("data.sqlite3", "sqlite3"),
|
||||
("DATA.CSV", "csv"),
|
||||
("DATA.JSON", "json"),
|
||||
("DATA.PARQUET", "parquet"),
|
||||
],
|
||||
)
|
||||
def test_auto_detect_from_extension(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
filename: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""Test format auto-detection from file extension."""
|
||||
result = detect_format(tmp_path / filename)
|
||||
assert result == expected
|
||||
|
||||
def test_unknown_extension_raises(self, tmp_path: Path) -> None:
|
||||
"""Test that unknown extension raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Cannot detect format"):
|
||||
detect_format(tmp_path / "data.xyz")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# export_dataframe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExportDataframe:
|
||||
"""Tests for export_dataframe."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_df(self) -> pd.DataFrame:
|
||||
"""Create a sample DataFrame for testing."""
|
||||
return pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
|
||||
|
||||
def test_export_csv(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test CSV export."""
|
||||
output = tmp_path / "out.csv"
|
||||
export_dataframe(sample_df, output, "csv")
|
||||
result = pd.read_csv(output)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_export_json(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test JSON export."""
|
||||
output = tmp_path / "out.json"
|
||||
export_dataframe(sample_df, output, "json")
|
||||
with output.open() as f:
|
||||
records = json.load(f)
|
||||
assert len(records) == 3
|
||||
assert records[0]["a"] == 1
|
||||
|
||||
def test_export_parquet(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test Parquet export."""
|
||||
output = tmp_path / "out.parquet"
|
||||
export_dataframe(sample_df, output, "parquet")
|
||||
result = pd.read_parquet(output)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_export_sqlite3(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test SQLite3 export."""
|
||||
output = tmp_path / "out.db"
|
||||
export_dataframe(sample_df, output, "sqlite3", table_name="test_table")
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT * FROM test_table",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_unsupported_format_raises(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
sample_df: pd.DataFrame,
|
||||
) -> None:
|
||||
"""Test that unsupported format raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Unsupported output format"):
|
||||
export_dataframe(sample_df, tmp_path / "out.txt", "xml")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseDatetime:
|
||||
"""Tests for parse_datetime."""
|
||||
|
||||
def test_valid_date(self) -> None:
|
||||
"""Test parsing a date string."""
|
||||
result = parse_datetime("2024-01-15")
|
||||
assert result == datetime(2024, 1, 15, tzinfo=UTC)
|
||||
|
||||
def test_valid_datetime_with_tz(self) -> None:
|
||||
"""Test parsing a datetime with timezone."""
|
||||
result = parse_datetime("2024-01-15T12:00:00+00:00")
|
||||
assert result == datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
def test_invalid_format_raises(self) -> None:
|
||||
"""Test that invalid format raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid datetime"):
|
||||
parse_datetime("not-a-date")
|
||||
|
||||
|
||||
class TestParseTimeframe:
|
||||
"""Tests for parse_timeframe."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("M1", 1), ("h1", 16385), ("D1", 16408), ("MN1", 49153)],
|
||||
)
|
||||
def test_named_timeframe(self, value: str, expected: int) -> None:
|
||||
"""Test parsing named timeframes."""
|
||||
assert parse_timeframe(value) == expected
|
||||
|
||||
def test_integer_timeframe(self) -> None:
|
||||
"""Test parsing integer timeframe."""
|
||||
assert parse_timeframe("42") == 42
|
||||
|
||||
def test_invalid_timeframe_raises(self) -> None:
|
||||
"""Test that invalid timeframe raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid timeframe"):
|
||||
parse_timeframe("INVALID")
|
||||
|
||||
|
||||
class TestParseTickFlags:
|
||||
"""Tests for parse_tick_flags."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("ALL", 1), ("info", 2), ("TRADE", 4)],
|
||||
)
|
||||
def test_named_flag(self, value: str, expected: int) -> None:
|
||||
"""Test parsing named tick flags."""
|
||||
assert parse_tick_flags(value) == expected
|
||||
|
||||
def test_integer_flag(self) -> None:
|
||||
"""Test parsing integer tick flag."""
|
||||
assert parse_tick_flags("7") == 7
|
||||
|
||||
def test_invalid_flag_raises(self) -> None:
|
||||
"""Test that invalid flag raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||
parse_tick_flags("INVALID")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseRequest:
|
||||
"""Tests for parse_request."""
|
||||
|
||||
def test_inline_json(self) -> None:
|
||||
"""Test parsing an inline JSON object string."""
|
||||
result = parse_request('{"action": 1, "symbol": "EURUSD"}')
|
||||
assert result == {"action": 1, "symbol": "EURUSD"}
|
||||
|
||||
def test_file_reference(self, tmp_path: Path) -> None:
|
||||
"""Test parsing JSON from a file via the @path syntax."""
|
||||
path = tmp_path / "req.json"
|
||||
path.write_text('{"action": 2}', encoding="utf-8")
|
||||
result = parse_request(f"@{path}")
|
||||
assert result == {"action": 2}
|
||||
|
||||
def test_invalid_json_raises(self) -> None:
|
||||
"""Test that invalid JSON raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid JSON request"):
|
||||
parse_request("not json")
|
||||
|
||||
def test_non_object_raises(self) -> None:
|
||||
"""Test that a non-object JSON raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must be a JSON object"):
|
||||
parse_request("[1, 2, 3]")
|
||||
|
||||
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
||||
"""Test that a missing request file raises ValueError."""
|
||||
path = tmp_path / "missing.json"
|
||||
with pytest.raises(ValueError, match="Failed to read JSON request file"):
|
||||
parse_request(f"@{path}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Tests for module constants."""
|
||||
|
||||
def test_timeframe_map_has_expected_keys(self) -> None:
|
||||
"""Test that TIMEFRAME_MAP contains standard timeframes."""
|
||||
for key in ("M1", "M5", "M15", "M30", "H1", "H4", "D1", "W1", "MN1"):
|
||||
assert key in TIMEFRAME_MAP
|
||||
|
||||
def test_tick_flag_map_has_expected_keys(self) -> None:
|
||||
"""Test that TICK_FLAG_MAP contains standard flags."""
|
||||
assert set(TICK_FLAG_MAP) == {"ALL", "INFO", "TRADE"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Click ParamTypes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDateTimeType:
|
||||
"""Tests for _DateTimeType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a string to datetime."""
|
||||
result = DATETIME_TYPE.convert("2024-06-15", None, None)
|
||||
assert result == datetime(2024, 6, 15, tzinfo=UTC)
|
||||
|
||||
def test_convert_datetime_passthrough(self) -> None:
|
||||
"""Test that datetime values pass through unchanged."""
|
||||
dt = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
assert DATETIME_TYPE.convert(dt, None, None) is dt
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid datetime"):
|
||||
DATETIME_TYPE.convert("bad", None, None)
|
||||
|
||||
|
||||
class TestTimeframeType:
|
||||
"""Tests for _TimeframeType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a string to timeframe integer."""
|
||||
assert TIMEFRAME_TYPE.convert("H1", None, None) == 16385
|
||||
|
||||
def test_convert_int_passthrough(self) -> None:
|
||||
"""Test that integer values pass through unchanged."""
|
||||
assert TIMEFRAME_TYPE.convert(42, None, None) == 42
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid timeframe"):
|
||||
TIMEFRAME_TYPE.convert("bad", None, None)
|
||||
|
||||
|
||||
class TestTickFlagsType:
|
||||
"""Tests for _TickFlagsType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a string to tick flags integer."""
|
||||
assert TICK_FLAGS_TYPE.convert("ALL", None, None) == 1
|
||||
|
||||
def test_convert_int_passthrough(self) -> None:
|
||||
"""Test that integer values pass through unchanged."""
|
||||
assert TICK_FLAGS_TYPE.convert(7, None, None) == 7
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid tick flags"):
|
||||
TICK_FLAGS_TYPE.convert("bad", None, None)
|
||||
|
||||
|
||||
class TestRequestType:
|
||||
"""Tests for _RequestType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a JSON string to a request dictionary."""
|
||||
assert REQUEST_TYPE.convert('{"action": 1}', None, None) == {"action": 1}
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid JSON request"):
|
||||
REQUEST_TYPE.convert("bad", None, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _execute_export
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -355,7 +51,7 @@ class TestExecuteExport:
|
||||
"""Test that shutdown is called even when fetch raises."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.account_info_as_df.side_effect = RuntimeError("boom")
|
||||
mocker.patch("mt5cli.cli.Mt5DataClient", return_value=mock_client)
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client)
|
||||
ctx = MagicMock()
|
||||
ctx.obj = _ExportContext(
|
||||
output=tmp_path / "out.csv",
|
||||
@@ -364,7 +60,7 @@ class TestExecuteExport:
|
||||
config=MagicMock(),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
_execute_export(ctx, lambda c: c.account_info_as_df())
|
||||
_execute_export(ctx, _sdk_client(ctx).account_info)
|
||||
mock_client.shutdown.assert_called_once()
|
||||
|
||||
|
||||
@@ -397,7 +93,7 @@ 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
|
||||
mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
return client
|
||||
|
||||
|
||||
@@ -931,7 +627,7 @@ class TestCallback:
|
||||
mock_client = MagicMock()
|
||||
mock_client.account_info_as_df.return_value = pd.DataFrame({"a": [1]})
|
||||
mocker.patch(
|
||||
"mt5cli.cli.Mt5DataClient",
|
||||
"mt5cli.sdk.Mt5DataClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
mock_config = mocker.patch("mt5cli.cli.Mt5Config")
|
||||
@@ -984,7 +680,7 @@ class TestCallback:
|
||||
{"s": ["EURUSD"]},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.cli.Mt5DataClient",
|
||||
"mt5cli.sdk.Mt5DataClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
output = tmp_path / "out.db"
|
||||
@@ -1090,7 +786,7 @@ def _build_history_client(mocker: MockerFixture) -> MagicMock:
|
||||
|
||||
client.history_orders_get_as_df.side_effect = _orders
|
||||
client.history_deals_get_as_df.side_effect = _deals
|
||||
mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
return client
|
||||
|
||||
|
||||
@@ -1393,7 +1089,7 @@ class TestCollectHistory:
|
||||
assert all(row[0] not in {0, 1} for row in cash)
|
||||
# Position 100 (BUY 1@1.10 + BUY 3@1.20 then SELL 4@1.50) is closed.
|
||||
# Position 200 (BUY 2@2.00 then SELL 2@2.20) is closed.
|
||||
# Position 300 (open-only) and 400 (reversal-only) are excluded.
|
||||
# Position 400 (reversal-only with non-trade deal type) stays excluded.
|
||||
assert set(positions) == {100, 200, 500, 600}
|
||||
pos_100 = positions[100]
|
||||
tol = 1e-9
|
||||
@@ -1410,10 +1106,10 @@ class TestCollectHistory:
|
||||
assert abs(pos_500[5] - 1.05) < tol
|
||||
pos_600 = positions[600]
|
||||
assert abs(pos_600[1] - 3.0) < tol
|
||||
assert abs(pos_600[2] - 3.0) < tol
|
||||
assert abs(pos_600[2] - 4.0) < tol # reversal + close volumes
|
||||
assert abs(pos_600[3] - 1.0) < tol
|
||||
assert abs(pos_600[4] - 1.10) < tol
|
||||
assert abs(pos_600[5] - 1.40) < tol
|
||||
assert abs(pos_600[5] - 3.5475) < tol
|
||||
assert pos_600[6] == 1
|
||||
|
||||
def test_collect_history_filters_history_symbols_exactly(
|
||||
@@ -1431,7 +1127,7 @@ class TestCollectHistory:
|
||||
"ticket": [3, 4],
|
||||
"symbol": ["EURUSD", "EURUSDm"],
|
||||
})
|
||||
mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
output = tmp_path / "history.db"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -1519,9 +1215,9 @@ class TestCollectHistory:
|
||||
client.copy_ticks_range_as_df.return_value = pd.DataFrame({"x": [1]})
|
||||
client.history_orders_get_as_df.return_value = pd.DataFrame({"x": [1]})
|
||||
client.history_deals_get_as_df.return_value = pd.DataFrame({"x": [1]})
|
||||
mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
output = tmp_path / "history.db"
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.cli"):
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.sdk"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
@@ -1559,7 +1255,7 @@ class TestCollectHistory:
|
||||
client = MagicMock()
|
||||
client.copy_rates_range_as_df.return_value = pd.DataFrame({"time": [1]})
|
||||
client.history_deals_get_as_df.return_value = pd.DataFrame()
|
||||
mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
output = tmp_path / "history.db"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -1598,7 +1294,7 @@ class TestCollectHistory:
|
||||
) -> None:
|
||||
"""Test that --with-views warns when history_deals is not written."""
|
||||
output = tmp_path / "history.db"
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.cli"):
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.sdk"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
|
||||
@@ -0,0 +1,810 @@
|
||||
"""Tests for mt5cli.sdk module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
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 sdk
|
||||
from mt5cli.sdk import (
|
||||
Mt5CliClient,
|
||||
account_info,
|
||||
build_config,
|
||||
collect_history,
|
||||
copy_rates_from,
|
||||
copy_rates_from_pos,
|
||||
copy_rates_range,
|
||||
copy_ticks_from,
|
||||
copy_ticks_range,
|
||||
history_deals,
|
||||
history_orders,
|
||||
last_error,
|
||||
market_book,
|
||||
orders,
|
||||
positions,
|
||||
symbol_info,
|
||||
symbol_info_tick,
|
||||
symbols,
|
||||
terminal_info,
|
||||
update_history,
|
||||
update_history_with_config,
|
||||
version,
|
||||
)
|
||||
from mt5cli.sqlite_history import DEFAULT_HISTORY_TIMEFRAMES
|
||||
from mt5cli.utils import Dataset
|
||||
|
||||
_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],
|
||||
"symbol": [
|
||||
"EURUSD",
|
||||
"EURUSD",
|
||||
"EURUSD",
|
||||
"",
|
||||
"EURUSD",
|
||||
"EURUSD",
|
||||
"GBPUSD",
|
||||
"GBPUSD",
|
||||
"GBPUSD",
|
||||
"EURUSD",
|
||||
"EURUSD",
|
||||
"GBPUSD",
|
||||
"GBPUSD",
|
||||
"GBPUSD",
|
||||
],
|
||||
"time": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
|
||||
"type": [0, 0, 1, 2, 0, 1, 0, 0, 2, 0, 1, 0, 1, 1],
|
||||
"entry": [0, 0, 1, 0, 0, 1, 0, 0, 2, 0, 3, 0, 2, 1],
|
||||
"volume": [1.0, 3.0, 4.0, 0.0, 2.0, 2.0, 5.0, 1.0, 1.0, 2.0, 2.0, 3.0, 1.0, 3.0],
|
||||
"price": [
|
||||
1.10,
|
||||
1.20,
|
||||
1.50,
|
||||
0.0,
|
||||
2.00,
|
||||
2.20,
|
||||
1.30,
|
||||
1.30,
|
||||
1.40,
|
||||
1.00,
|
||||
1.05,
|
||||
1.10,
|
||||
9.99,
|
||||
1.40,
|
||||
],
|
||||
"profit": [0.0, 0.0, 10.0, 5.0, 0.0, 8.0, 0.0, 0.0, -1.0, 0.0, 3.0, 0.0, -2.0, 7.0],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client(mocker: MockerFixture) -> MagicMock:
|
||||
"""Create and patch a mock Mt5DataClient for SDK tests."""
|
||||
client = MagicMock()
|
||||
sample_df = pd.DataFrame({"col": [1]})
|
||||
client.copy_rates_from_as_df.return_value = sample_df
|
||||
client.copy_rates_from_pos_as_df.return_value = sample_df
|
||||
client.copy_rates_range_as_df.return_value = sample_df
|
||||
client.copy_ticks_from_as_df.return_value = sample_df
|
||||
client.copy_ticks_range_as_df.return_value = sample_df
|
||||
client.account_info_as_df.return_value = sample_df
|
||||
client.terminal_info_as_df.return_value = sample_df
|
||||
client.symbols_get_as_df.return_value = sample_df
|
||||
client.symbol_info_as_df.return_value = sample_df
|
||||
client.orders_get_as_df.return_value = sample_df
|
||||
client.positions_get_as_df.return_value = sample_df
|
||||
client.history_orders_get_as_df.return_value = sample_df
|
||||
client.history_deals_get_as_df.return_value = sample_df
|
||||
client.version_as_df.return_value = sample_df
|
||||
client.last_error_as_df.return_value = sample_df
|
||||
client.symbol_info_tick_as_df.return_value = sample_df
|
||||
client.market_book_get_as_df.return_value = sample_df
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
return client
|
||||
|
||||
|
||||
def _build_history_client(mocker: MockerFixture) -> MagicMock:
|
||||
"""Build a mocked Mt5DataClient with per-symbol history results."""
|
||||
client = MagicMock()
|
||||
|
||||
def _rates(**kwargs: object) -> pd.DataFrame:
|
||||
return pd.DataFrame({
|
||||
"time": [1],
|
||||
"open": [1.0],
|
||||
"symbol_arg": [kwargs.get("symbol")],
|
||||
})
|
||||
|
||||
def _ticks(**kwargs: object) -> pd.DataFrame:
|
||||
return pd.DataFrame({
|
||||
"time": [1],
|
||||
"bid": [1.0],
|
||||
"symbol_arg": [kwargs.get("symbol")],
|
||||
})
|
||||
|
||||
client.copy_rates_range_as_df.side_effect = _rates
|
||||
client.copy_ticks_range_as_df.side_effect = _ticks
|
||||
|
||||
def _orders(**kwargs: object) -> pd.DataFrame:
|
||||
return pd.DataFrame({"ticket": [10], "symbol": [kwargs.get("symbol")]})
|
||||
|
||||
def _deals(**kwargs: object) -> pd.DataFrame:
|
||||
sym = kwargs.get("symbol")
|
||||
df = pd.DataFrame(_DEALS_FIXTURE)
|
||||
return df[df["symbol"] == sym].reset_index(drop=True)
|
||||
|
||||
client.history_orders_get_as_df.side_effect = _orders
|
||||
client.history_deals_get_as_df.side_effect = _deals
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
return client
|
||||
|
||||
|
||||
class TestConnectionLifecycle:
|
||||
"""Tests for MT5 connection lifecycle helpers."""
|
||||
|
||||
def test_connected_client_shuts_down(self, mocker: MockerFixture) -> None:
|
||||
"""Test that _connected_client always shuts down."""
|
||||
mock_client = MagicMock()
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client)
|
||||
config = MagicMock()
|
||||
with sdk._connected_client(config): # type: ignore[reportPrivateUsage]
|
||||
mock_client.initialize_and_login_mt5.assert_called_once()
|
||||
mock_client.shutdown.assert_called_once()
|
||||
|
||||
def test_connected_client_shutdown_on_init_failure(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test that shutdown is called when initialize/login fails."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.initialize_and_login_mt5.side_effect = RuntimeError(
|
||||
"login failed",
|
||||
)
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client)
|
||||
with (
|
||||
pytest.raises(RuntimeError, match="login failed"),
|
||||
sdk._connected_client(MagicMock()), # type: ignore[reportPrivateUsage]
|
||||
):
|
||||
pass
|
||||
mock_client.shutdown.assert_called_once()
|
||||
|
||||
def test_run_with_client_shutdown_on_error(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test that shutdown is called even when fetch raises."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.account_info_as_df.side_effect = RuntimeError("boom")
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client)
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
sdk._run_with_client( # type: ignore[reportPrivateUsage]
|
||||
MagicMock(),
|
||||
lambda c: c.account_info_as_df(),
|
||||
)
|
||||
mock_client.shutdown.assert_called_once()
|
||||
|
||||
def test_client_context_manager_reuses_connection(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test that context-managed client reuses one connection."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.account_info_as_df.return_value = pd.DataFrame({"a": [1]})
|
||||
mock_client.terminal_info_as_df.return_value = pd.DataFrame({"b": [2]})
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client)
|
||||
with Mt5CliClient() as client:
|
||||
client.account_info()
|
||||
client.terminal_info()
|
||||
assert client.config is not None
|
||||
mock_client.initialize_and_login_mt5.assert_called_once()
|
||||
mock_client.shutdown.assert_called_once()
|
||||
assert mock_client.account_info_as_df.call_count == 1
|
||||
assert mock_client.terminal_info_as_df.call_count == 1
|
||||
|
||||
def test_client_context_manager_shutdown_on_init_failure(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test that shutdown is called when context manager login fails."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.initialize_and_login_mt5.side_effect = RuntimeError(
|
||||
"login failed",
|
||||
)
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client)
|
||||
client = Mt5CliClient()
|
||||
with pytest.raises(RuntimeError, match="login failed"), client:
|
||||
pass
|
||||
mock_client.shutdown.assert_called_once()
|
||||
assert client._client is None # type: ignore[reportPrivateUsage]
|
||||
|
||||
def test_exit_without_enter_is_noop(self) -> None:
|
||||
"""Test that __exit__ without __enter__ does not fail."""
|
||||
client = Mt5CliClient()
|
||||
client.__exit__(None, None, None)
|
||||
|
||||
|
||||
class TestModuleFunctions:
|
||||
"""Tests for module-level SDK wrappers."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fn", "args", "method"),
|
||||
[
|
||||
(
|
||||
copy_rates_from,
|
||||
("EURUSD", "M1", "2024-01-01", 10),
|
||||
"copy_rates_from_as_df",
|
||||
),
|
||||
(
|
||||
copy_rates_from_pos,
|
||||
("EURUSD", "M1", 0, 10),
|
||||
"copy_rates_from_pos_as_df",
|
||||
),
|
||||
(
|
||||
copy_ticks_from,
|
||||
("EURUSD", "2024-01-01", 10, "ALL"),
|
||||
"copy_ticks_from_as_df",
|
||||
),
|
||||
(
|
||||
copy_ticks_range,
|
||||
("EURUSD", "2024-01-01", "2024-02-01", "ALL"),
|
||||
"copy_ticks_range_as_df",
|
||||
),
|
||||
(account_info, (), "account_info_as_df"),
|
||||
(terminal_info, (), "terminal_info_as_df"),
|
||||
(symbols, ("*USD*",), "symbols_get_as_df"),
|
||||
(symbol_info, ("EURUSD",), "symbol_info_as_df"),
|
||||
(orders, (), "orders_get_as_df"),
|
||||
(positions, (), "positions_get_as_df"),
|
||||
(history_orders, (), "history_orders_get_as_df"),
|
||||
(history_deals, (), "history_deals_get_as_df"),
|
||||
(version, (), "version_as_df"),
|
||||
(last_error, (), "last_error_as_df"),
|
||||
(symbol_info_tick, ("EURUSD",), "symbol_info_tick_as_df"),
|
||||
(market_book, ("EURUSD",), "market_book_get_as_df"),
|
||||
],
|
||||
)
|
||||
def test_module_functions_delegate(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
fn: object,
|
||||
args: tuple[object, ...],
|
||||
method: str,
|
||||
) -> None:
|
||||
"""Test module-level functions call the expected client methods."""
|
||||
config = build_config(login=123)
|
||||
result = fn(*args, config=config) # type: ignore[operator]
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
getattr(mock_client, method).assert_called_once()
|
||||
|
||||
|
||||
class TestMt5CliClient:
|
||||
"""Tests for Mt5CliClient SDK methods."""
|
||||
|
||||
def test_copy_rates_range_returns_dataframe(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that copy_rates_range returns a DataFrame."""
|
||||
df = Mt5CliClient().copy_rates_range(
|
||||
"EURUSD",
|
||||
"D1",
|
||||
"2024-01-01",
|
||||
"2024-02-01",
|
||||
)
|
||||
assert isinstance(df, pd.DataFrame)
|
||||
mock_client.copy_rates_range_as_df.assert_called_once_with(
|
||||
symbol="EURUSD",
|
||||
timeframe=16408,
|
||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
||||
)
|
||||
|
||||
def test_copy_ticks_from_parses_flags(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that string tick flags are parsed."""
|
||||
Mt5CliClient().copy_ticks_from("EURUSD", "2024-01-01", 100, "INFO")
|
||||
mock_client.copy_ticks_from_as_df.assert_called_once_with(
|
||||
symbol="EURUSD",
|
||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
count=100,
|
||||
flags=2,
|
||||
)
|
||||
|
||||
def test_history_orders_accepts_string_dates(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that string datetime inputs are parsed."""
|
||||
Mt5CliClient().history_orders(
|
||||
date_from="2024-01-01",
|
||||
date_to="2024-02-01",
|
||||
)
|
||||
mock_client.history_orders_get_as_df.assert_called_once_with(
|
||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
||||
group=None,
|
||||
symbol=None,
|
||||
ticket=None,
|
||||
position=None,
|
||||
)
|
||||
|
||||
def test_module_function_delegates_to_client(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test module-level copy_rates_range delegates to the client."""
|
||||
df = copy_rates_range(
|
||||
"USDJPY",
|
||||
"M1",
|
||||
"2024-01-01",
|
||||
"2024-02-01",
|
||||
)
|
||||
assert isinstance(df, pd.DataFrame)
|
||||
mock_client.copy_rates_range_as_df.assert_called_once()
|
||||
|
||||
|
||||
class TestCollectHistory:
|
||||
"""Tests for collect_history SDK function."""
|
||||
|
||||
@pytest.fixture
|
||||
def history_client(self, mocker: MockerFixture) -> MagicMock:
|
||||
"""Create a mocked Mt5DataClient with history-style DataFrames."""
|
||||
return _build_history_client(mocker)
|
||||
|
||||
def test_collect_history_writes_all_tables(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
history_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that collect_history writes rates, ticks, and history tables."""
|
||||
output = tmp_path / "history.db"
|
||||
collect_history(
|
||||
output,
|
||||
["EURUSD", "GBPUSD"],
|
||||
"2024-01-01",
|
||||
"2024-02-01",
|
||||
)
|
||||
assert history_client.copy_rates_range_as_df.call_count == 2
|
||||
assert history_client.copy_ticks_range_as_df.call_count == 2
|
||||
with sqlite3.connect(output) as conn:
|
||||
tables = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'",
|
||||
).fetchall()
|
||||
}
|
||||
assert {"rates", "ticks", "history_orders", "history_deals"} <= tables
|
||||
|
||||
def test_collect_history_with_views(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
history_client: MagicMock, # noqa: ARG002
|
||||
) -> None:
|
||||
"""Test that with_views creates cash_events and positions views."""
|
||||
output = tmp_path / "history.db"
|
||||
collect_history(
|
||||
output,
|
||||
["EURUSD", "GBPUSD"],
|
||||
"2024-01-01",
|
||||
"2024-02-01",
|
||||
with_views=True,
|
||||
)
|
||||
with sqlite3.connect(output) as conn:
|
||||
views = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='view'",
|
||||
).fetchall()
|
||||
}
|
||||
positions = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT position_id FROM positions_reconstructed",
|
||||
).fetchall()
|
||||
}
|
||||
assert {"cash_events", "positions_reconstructed"} <= views
|
||||
assert set(positions) == {100, 200, 500, 600}
|
||||
|
||||
def test_collect_history_rates_table_has_timeframe(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
history_client: MagicMock, # noqa: ARG002
|
||||
) -> None:
|
||||
"""Test that the rates table carries the requested timeframe value."""
|
||||
output = tmp_path / "history.db"
|
||||
collect_history(
|
||||
output,
|
||||
["EURUSD"],
|
||||
"2024-01-01",
|
||||
"2024-02-01",
|
||||
datasets={Dataset.rates},
|
||||
timeframe="H1",
|
||||
)
|
||||
with sqlite3.connect(output) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT timeframe FROM rates",
|
||||
).fetchall()
|
||||
assert rows == [(16385,)]
|
||||
|
||||
def test_collect_history_views_skipped_when_columns_missing(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
mocker: MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that views are not created when required columns are missing."""
|
||||
client = MagicMock()
|
||||
client.copy_rates_range_as_df.return_value = pd.DataFrame({"x": [1]})
|
||||
client.copy_ticks_range_as_df.return_value = pd.DataFrame({"x": [1]})
|
||||
client.history_orders_get_as_df.return_value = pd.DataFrame({"x": [1]})
|
||||
client.history_deals_get_as_df.return_value = pd.DataFrame({"x": [1]})
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
|
||||
output = tmp_path / "history.db"
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.sdk"):
|
||||
collect_history(
|
||||
output,
|
||||
["EURUSD"],
|
||||
"2024-01-01",
|
||||
"2024-02-01",
|
||||
with_views=True,
|
||||
)
|
||||
with sqlite3.connect(output) as conn:
|
||||
views = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='view'",
|
||||
).fetchall()
|
||||
}
|
||||
assert "cash_events" not in views
|
||||
assert "positions_reconstructed" not in views
|
||||
|
||||
|
||||
class TestUpdateHistory:
|
||||
"""Tests for update_history SDK functions."""
|
||||
|
||||
@pytest.fixture
|
||||
def connected_client(self) -> MagicMock:
|
||||
"""Create a connected mock client without MT5 lifecycle patching."""
|
||||
return MagicMock()
|
||||
|
||||
def test_update_history_appends_incrementally(
|
||||
self,
|
||||
connected_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test sequential SQLite history updates use existing max timestamps."""
|
||||
date_to = datetime(2024, 1, 2, tzinfo=UTC)
|
||||
first_expected_start = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
second_expected_start = datetime(2024, 1, 1, 12, tzinfo=UTC)
|
||||
rate_starts: list[datetime] = []
|
||||
deal_starts: list[datetime] = []
|
||||
|
||||
def make_rates(**kwargs: object) -> pd.DataFrame:
|
||||
assert kwargs["symbol"] == "EURUSD"
|
||||
assert kwargs["timeframe"] == 1
|
||||
assert kwargs["date_to"] == date_to
|
||||
rate_starts.append(kwargs["date_from"]) # type: ignore[arg-type]
|
||||
return pd.DataFrame({
|
||||
"time": ["2024-01-01T12:00:00+00:00"],
|
||||
"open": [1.0 + len(rate_starts) / 10],
|
||||
})
|
||||
|
||||
def make_deals(**kwargs: object) -> pd.DataFrame:
|
||||
assert kwargs["date_to"] == date_to
|
||||
deal_starts.append(kwargs["date_from"]) # type: ignore[arg-type]
|
||||
return pd.DataFrame({
|
||||
"ticket": [10],
|
||||
"position_id": [100],
|
||||
"symbol": ["EURUSD"],
|
||||
"time": ["2024-01-01T12:00:00+00:00"],
|
||||
"type": [0],
|
||||
"entry": [0],
|
||||
"volume": [1.0],
|
||||
"price": [1.1],
|
||||
"profit": [0.0],
|
||||
})
|
||||
|
||||
connected_client.copy_rates_range_as_df.side_effect = make_rates
|
||||
connected_client.history_deals_get_as_df.side_effect = make_deals
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient")
|
||||
output = tmp_path / "incremental-history.db"
|
||||
|
||||
for _ in range(2):
|
||||
update_history(
|
||||
client=connected_client,
|
||||
output=output,
|
||||
symbols=["EURUSD"],
|
||||
datasets={Dataset.rates, Dataset.history_deals},
|
||||
timeframes=["M1"],
|
||||
lookback_hours=24,
|
||||
date_to=date_to,
|
||||
with_views=True,
|
||||
)
|
||||
|
||||
assert rate_starts == [first_expected_start, second_expected_start]
|
||||
assert deal_starts == [first_expected_start, first_expected_start]
|
||||
connected_client.initialize_and_login_mt5.assert_not_called()
|
||||
connected_client.shutdown.assert_not_called()
|
||||
with sqlite3.connect(output) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM rates").fetchone() == (1,)
|
||||
assert conn.execute("SELECT open FROM rates").fetchone() == (1.2,)
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM history_deals",
|
||||
).fetchone() == (1,)
|
||||
assert conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'cash_events'",
|
||||
).fetchone() == ("cash_events",)
|
||||
|
||||
def test_update_history_rejects_invalid_inputs(
|
||||
self,
|
||||
connected_client: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test validation errors for incremental history updates."""
|
||||
output = tmp_path / "invalid-update.db"
|
||||
with pytest.raises(ValueError, match="At least one symbol"):
|
||||
update_history(
|
||||
client=connected_client,
|
||||
output=output,
|
||||
symbols=[],
|
||||
)
|
||||
with pytest.raises(ValueError, match="lookback_hours must be positive"):
|
||||
update_history(
|
||||
client=connected_client,
|
||||
output=output,
|
||||
symbols=["EURUSD"],
|
||||
lookback_hours=0,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Invalid timeframe"):
|
||||
update_history(
|
||||
client=connected_client,
|
||||
output=output,
|
||||
symbols=["EURUSD"],
|
||||
datasets={Dataset.rates},
|
||||
timeframes=["BAD"],
|
||||
)
|
||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||
update_history(
|
||||
client=connected_client,
|
||||
output=output,
|
||||
symbols=["EURUSD"],
|
||||
datasets={Dataset.ticks},
|
||||
flags="BAD",
|
||||
)
|
||||
|
||||
def test_update_history_noops_for_empty_datasets(
|
||||
self,
|
||||
connected_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test empty dataset selection skips MT5 and SQLite writes."""
|
||||
writer = mocker.patch("mt5cli.sdk.write_incremental_datasets")
|
||||
connect = mocker.patch("mt5cli.sdk.sqlite3.connect")
|
||||
update_history(
|
||||
client=connected_client,
|
||||
output=tmp_path / "empty-datasets.db",
|
||||
symbols=["EURUSD"],
|
||||
datasets=set(),
|
||||
)
|
||||
writer.assert_not_called()
|
||||
connect.assert_not_called()
|
||||
|
||||
def test_update_history_uses_all_default_timeframes(
|
||||
self,
|
||||
connected_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that timeframes=None writes rates for all default MT5 timeframes."""
|
||||
timeframes_written: list[int] = []
|
||||
|
||||
def capture(
|
||||
*args: object,
|
||||
**_kwargs: object,
|
||||
) -> tuple[set[Dataset], dict[Dataset, set[str]]]:
|
||||
timeframes_written.extend(args[4]) # type: ignore[arg-type]
|
||||
return set(), {}
|
||||
|
||||
mocker.patch("mt5cli.sdk.write_incremental_datasets", side_effect=capture)
|
||||
update_history(
|
||||
client=connected_client,
|
||||
output=tmp_path / "default-timeframes.db",
|
||||
symbols=["EURUSD"],
|
||||
datasets={Dataset.rates},
|
||||
timeframes=None,
|
||||
lookback_hours=1,
|
||||
date_to=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
assert len(timeframes_written) == len(DEFAULT_HISTORY_TIMEFRAMES)
|
||||
|
||||
def test_update_history_uses_specified_timeframes(
|
||||
self,
|
||||
connected_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test explicit timeframes limit rate updates."""
|
||||
timeframes_written: list[int] = []
|
||||
|
||||
def capture(
|
||||
*args: object,
|
||||
**_kwargs: object,
|
||||
) -> tuple[set[Dataset], dict[Dataset, set[str]]]:
|
||||
timeframes_written.extend(args[4]) # type: ignore[arg-type]
|
||||
return set(), {}
|
||||
|
||||
mocker.patch("mt5cli.sdk.write_incremental_datasets", side_effect=capture)
|
||||
update_history(
|
||||
client=connected_client,
|
||||
output=tmp_path / "specific-timeframes.db",
|
||||
symbols=["EURUSD"],
|
||||
datasets={Dataset.rates},
|
||||
timeframes=["M1", "H1"],
|
||||
lookback_hours=1,
|
||||
date_to=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
assert timeframes_written == [1, 16385]
|
||||
|
||||
def test_update_history_updates_ticks_and_orders(
|
||||
self,
|
||||
connected_client: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test incremental update writes selected ticks and orders datasets."""
|
||||
date_to = datetime(2024, 1, 2, tzinfo=UTC)
|
||||
expected_start = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
|
||||
def make_ticks(**kwargs: object) -> pd.DataFrame:
|
||||
assert kwargs["symbol"] == "EURUSD"
|
||||
assert kwargs["date_from"] == expected_start
|
||||
assert kwargs["date_to"] == date_to
|
||||
assert kwargs["flags"] == 1
|
||||
return pd.DataFrame({
|
||||
"time": ["2024-01-01T12:00:00+00:00"],
|
||||
"time_msc": [1_704_110_400_000],
|
||||
"bid": [1.1],
|
||||
})
|
||||
|
||||
def make_orders(**kwargs: object) -> pd.DataFrame:
|
||||
assert kwargs["symbol"] == "EURUSD"
|
||||
assert kwargs["date_from"] == expected_start
|
||||
assert kwargs["date_to"] == date_to
|
||||
return pd.DataFrame({
|
||||
"ticket": [1],
|
||||
"symbol": ["EURUSD"],
|
||||
"time": ["2024-01-01T12:00:00+00:00"],
|
||||
"type": [0],
|
||||
})
|
||||
|
||||
connected_client.copy_ticks_range_as_df.side_effect = make_ticks
|
||||
connected_client.history_orders_get_as_df.side_effect = make_orders
|
||||
output = tmp_path / "ticks-orders.db"
|
||||
update_history(
|
||||
client=connected_client,
|
||||
output=output,
|
||||
symbols=["EURUSD"],
|
||||
datasets={Dataset.ticks, Dataset.history_orders},
|
||||
lookback_hours=24,
|
||||
date_to=date_to,
|
||||
)
|
||||
with sqlite3.connect(output) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM ticks").fetchone() == (1,)
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM history_orders",
|
||||
).fetchone() == (1,)
|
||||
|
||||
def test_update_history_with_config_opens_and_closes_connection(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test update_history_with_config manages MT5 connection lifecycle."""
|
||||
mock_client = MagicMock()
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client)
|
||||
updater = mocker.patch("mt5cli.sdk.update_history")
|
||||
update_history_with_config(
|
||||
output=tmp_path / "config-wrapper.db",
|
||||
symbols=["EURUSD"],
|
||||
datasets={Dataset.history_deals},
|
||||
timeframes=["M1"],
|
||||
flags="ALL",
|
||||
lookback_hours=1,
|
||||
date_to=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
deduplicate=False,
|
||||
create_rate_views=False,
|
||||
with_views=True,
|
||||
include_account_events=False,
|
||||
)
|
||||
mock_client.initialize_and_login_mt5.assert_called_once()
|
||||
mock_client.shutdown.assert_called_once()
|
||||
updater.assert_called_once()
|
||||
assert updater.call_args.kwargs == {
|
||||
"client": mock_client,
|
||||
"output": tmp_path / "config-wrapper.db",
|
||||
"symbols": ["EURUSD"],
|
||||
"datasets": {Dataset.history_deals},
|
||||
"timeframes": ["M1"],
|
||||
"flags": "ALL",
|
||||
"lookback_hours": 1,
|
||||
"date_to": datetime(2024, 1, 1, tzinfo=UTC),
|
||||
"deduplicate": False,
|
||||
"create_rate_views": False,
|
||||
"with_views": True,
|
||||
"include_account_events": False,
|
||||
}
|
||||
|
||||
def test_update_history_with_config_validates_before_connecting(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test invalid inputs fail before MT5 is initialized."""
|
||||
mock_client = MagicMock()
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client)
|
||||
with pytest.raises(ValueError, match="lookback_hours must be positive"):
|
||||
update_history_with_config(
|
||||
output=tmp_path / "invalid-config.db",
|
||||
symbols=["EURUSD"],
|
||||
lookback_hours=0,
|
||||
)
|
||||
mock_client.initialize_and_login_mt5.assert_not_called()
|
||||
mock_client.shutdown.assert_not_called()
|
||||
|
||||
def test_update_history_with_config_noops_for_empty_datasets(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test empty dataset selection skips MT5 initialization."""
|
||||
mock_client = MagicMock()
|
||||
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client)
|
||||
updater = mocker.patch("mt5cli.sdk.update_history")
|
||||
update_history_with_config(
|
||||
output=tmp_path / "empty-config.db",
|
||||
symbols=["EURUSD"],
|
||||
datasets=set(),
|
||||
)
|
||||
mock_client.initialize_and_login_mt5.assert_not_called()
|
||||
mock_client.shutdown.assert_not_called()
|
||||
updater.assert_not_called()
|
||||
|
||||
def test_update_history_defaults_date_to_now(
|
||||
self,
|
||||
connected_client: MagicMock,
|
||||
mocker: MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test update_history uses current UTC time when date_to is omitted."""
|
||||
captured: dict[str, datetime] = {}
|
||||
|
||||
def capture(
|
||||
*args: object,
|
||||
**_kwargs: object,
|
||||
) -> tuple[set[Dataset], dict[Dataset, set[str]]]:
|
||||
captured["end"] = args[7] # type: ignore[assignment]
|
||||
return set(), {}
|
||||
|
||||
mocker.patch("mt5cli.sdk.write_incremental_datasets", side_effect=capture)
|
||||
before = datetime.now(UTC)
|
||||
update_history(
|
||||
client=connected_client,
|
||||
output=tmp_path / "now-default.db",
|
||||
symbols=["EURUSD"],
|
||||
datasets={Dataset.rates},
|
||||
timeframes=["M1"],
|
||||
lookback_hours=12,
|
||||
)
|
||||
after = datetime.now(UTC)
|
||||
assert before <= captured["end"] <= after
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
"""Tests for mt5cli.utils module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from mt5cli.utils import (
|
||||
DATETIME_TYPE,
|
||||
REQUEST_TYPE,
|
||||
TICK_FLAG_MAP,
|
||||
TICK_FLAGS_TYPE,
|
||||
TIMEFRAME_MAP,
|
||||
TIMEFRAME_TYPE,
|
||||
Dataset,
|
||||
detect_format,
|
||||
export_dataframe,
|
||||
parse_datetime,
|
||||
parse_request,
|
||||
parse_tick_flags,
|
||||
parse_timeframe,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectFormat:
|
||||
"""Tests for detect_format."""
|
||||
|
||||
def test_explicit_format_returned(self, tmp_path: Path) -> None:
|
||||
"""Test that explicit format overrides extension."""
|
||||
result = detect_format(tmp_path / "data.txt", explicit_format="csv")
|
||||
assert result == "csv"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "expected"),
|
||||
[
|
||||
("data.csv", "csv"),
|
||||
("data.json", "json"),
|
||||
("data.parquet", "parquet"),
|
||||
("data.pq", "parquet"),
|
||||
("data.db", "sqlite3"),
|
||||
("data.sqlite", "sqlite3"),
|
||||
("data.sqlite3", "sqlite3"),
|
||||
("DATA.CSV", "csv"),
|
||||
("DATA.JSON", "json"),
|
||||
("DATA.PARQUET", "parquet"),
|
||||
],
|
||||
)
|
||||
def test_auto_detect_from_extension(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
filename: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""Test format auto-detection from file extension."""
|
||||
result = detect_format(tmp_path / filename)
|
||||
assert result == expected
|
||||
|
||||
def test_unknown_extension_raises(self, tmp_path: Path) -> None:
|
||||
"""Test that unknown extension raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Cannot detect format"):
|
||||
detect_format(tmp_path / "data.xyz")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# export_dataframe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExportDataframe:
|
||||
"""Tests for export_dataframe."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_df(self) -> pd.DataFrame:
|
||||
"""Create a sample DataFrame for testing."""
|
||||
return pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
|
||||
|
||||
def test_export_csv(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test CSV export."""
|
||||
output = tmp_path / "out.csv"
|
||||
export_dataframe(sample_df, output, "csv")
|
||||
result = pd.read_csv(output)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_export_json(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test JSON export."""
|
||||
output = tmp_path / "out.json"
|
||||
export_dataframe(sample_df, output, "json")
|
||||
with output.open() as f:
|
||||
records = json.load(f)
|
||||
assert len(records) == 3
|
||||
assert records[0]["a"] == 1
|
||||
|
||||
def test_export_parquet(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test Parquet export."""
|
||||
output = tmp_path / "out.parquet"
|
||||
export_dataframe(sample_df, output, "parquet")
|
||||
result = pd.read_parquet(output)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_export_sqlite3(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||
"""Test SQLite3 export."""
|
||||
output = tmp_path / "out.db"
|
||||
export_dataframe(sample_df, output, "sqlite3", table_name="test_table")
|
||||
with sqlite3.connect(output) as conn:
|
||||
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
|
||||
"SELECT * FROM test_table",
|
||||
conn,
|
||||
)
|
||||
pd.testing.assert_frame_equal(result, sample_df)
|
||||
|
||||
def test_unsupported_format_raises(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
sample_df: pd.DataFrame,
|
||||
) -> None:
|
||||
"""Test that unsupported format raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Unsupported output format"):
|
||||
export_dataframe(sample_df, tmp_path / "out.txt", "xml")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseDatetime:
|
||||
"""Tests for parse_datetime."""
|
||||
|
||||
def test_valid_date(self) -> None:
|
||||
"""Test parsing a date string."""
|
||||
result = parse_datetime("2024-01-15")
|
||||
assert result == datetime(2024, 1, 15, tzinfo=UTC)
|
||||
|
||||
def test_valid_datetime_with_tz(self) -> None:
|
||||
"""Test parsing a datetime with timezone."""
|
||||
result = parse_datetime("2024-01-15T12:00:00+00:00")
|
||||
assert result == datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
def test_invalid_format_raises(self) -> None:
|
||||
"""Test that invalid format raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid datetime"):
|
||||
parse_datetime("not-a-date")
|
||||
|
||||
|
||||
class TestParseTimeframe:
|
||||
"""Tests for parse_timeframe."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("M1", 1), ("h1", 16385), ("D1", 16408), ("MN1", 49153)],
|
||||
)
|
||||
def test_named_timeframe(self, value: str, expected: int) -> None:
|
||||
"""Test parsing named timeframes."""
|
||||
assert parse_timeframe(value) == expected
|
||||
|
||||
def test_integer_timeframe(self) -> None:
|
||||
"""Test parsing integer timeframe."""
|
||||
assert parse_timeframe("42") == 42
|
||||
|
||||
def test_invalid_timeframe_raises(self) -> None:
|
||||
"""Test that invalid timeframe raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid timeframe"):
|
||||
parse_timeframe("INVALID")
|
||||
|
||||
|
||||
class TestParseTickFlags:
|
||||
"""Tests for parse_tick_flags."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[("ALL", 1), ("info", 2), ("TRADE", 4)],
|
||||
)
|
||||
def test_named_flag(self, value: str, expected: int) -> None:
|
||||
"""Test parsing named tick flags."""
|
||||
assert parse_tick_flags(value) == expected
|
||||
|
||||
def test_integer_flag(self) -> None:
|
||||
"""Test parsing integer tick flag."""
|
||||
assert parse_tick_flags("7") == 7
|
||||
|
||||
def test_invalid_flag_raises(self) -> None:
|
||||
"""Test that invalid flag raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||
parse_tick_flags("INVALID")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseRequest:
|
||||
"""Tests for parse_request."""
|
||||
|
||||
def test_inline_json(self) -> None:
|
||||
"""Test parsing an inline JSON object string."""
|
||||
result = parse_request('{"action": 1, "symbol": "EURUSD"}')
|
||||
assert result == {"action": 1, "symbol": "EURUSD"}
|
||||
|
||||
def test_file_reference(self, tmp_path: Path) -> None:
|
||||
"""Test parsing JSON from a file via the @path syntax."""
|
||||
path = tmp_path / "req.json"
|
||||
path.write_text('{"action": 2}', encoding="utf-8")
|
||||
result = parse_request(f"@{path}")
|
||||
assert result == {"action": 2}
|
||||
|
||||
def test_invalid_json_raises(self) -> None:
|
||||
"""Test that invalid JSON raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid JSON request"):
|
||||
parse_request("not json")
|
||||
|
||||
def test_non_object_raises(self) -> None:
|
||||
"""Test that a non-object JSON raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must be a JSON object"):
|
||||
parse_request("[1, 2, 3]")
|
||||
|
||||
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
||||
"""Test that a missing request file raises ValueError."""
|
||||
path = tmp_path / "missing.json"
|
||||
with pytest.raises(ValueError, match="Failed to read JSON request file"):
|
||||
parse_request(f"@{path}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Tests for module constants."""
|
||||
|
||||
def test_timeframe_map_has_expected_keys(self) -> None:
|
||||
"""Test that TIMEFRAME_MAP contains standard timeframes."""
|
||||
for key in ("M1", "M5", "M15", "M30", "H1", "H4", "D1", "W1", "MN1"):
|
||||
assert key in TIMEFRAME_MAP
|
||||
|
||||
def test_tick_flag_map_has_expected_keys(self) -> None:
|
||||
"""Test that TICK_FLAG_MAP contains standard flags."""
|
||||
assert set(TICK_FLAG_MAP) == {"ALL", "INFO", "TRADE"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dataset", "expected"),
|
||||
[
|
||||
(Dataset.rates, "rates"),
|
||||
(Dataset.ticks, "ticks"),
|
||||
(Dataset.history_orders, "history_orders"),
|
||||
(Dataset.history_deals, "history_deals"),
|
||||
],
|
||||
)
|
||||
def test_dataset_table_name(self, dataset: Dataset, expected: str) -> None:
|
||||
"""Test dataset SQLite table names."""
|
||||
assert dataset.table_name == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Click ParamTypes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDateTimeType:
|
||||
"""Tests for _DateTimeType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a string to datetime."""
|
||||
result = DATETIME_TYPE.convert("2024-06-15", None, None)
|
||||
assert result == datetime(2024, 6, 15, tzinfo=UTC)
|
||||
|
||||
def test_convert_datetime_passthrough(self) -> None:
|
||||
"""Test that datetime values pass through unchanged."""
|
||||
dt = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
assert DATETIME_TYPE.convert(dt, None, None) is dt
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid datetime"):
|
||||
DATETIME_TYPE.convert("bad", None, None)
|
||||
|
||||
|
||||
class TestTimeframeType:
|
||||
"""Tests for _TimeframeType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a string to timeframe integer."""
|
||||
assert TIMEFRAME_TYPE.convert("H1", None, None) == 16385
|
||||
|
||||
def test_convert_int_passthrough(self) -> None:
|
||||
"""Test that integer values pass through unchanged."""
|
||||
assert TIMEFRAME_TYPE.convert(42, None, None) == 42
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid timeframe"):
|
||||
TIMEFRAME_TYPE.convert("bad", None, None)
|
||||
|
||||
|
||||
class TestTickFlagsType:
|
||||
"""Tests for _TickFlagsType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a string to tick flags integer."""
|
||||
assert TICK_FLAGS_TYPE.convert("ALL", None, None) == 1
|
||||
|
||||
def test_convert_int_passthrough(self) -> None:
|
||||
"""Test that integer values pass through unchanged."""
|
||||
assert TICK_FLAGS_TYPE.convert(7, None, None) == 7
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid tick flags"):
|
||||
TICK_FLAGS_TYPE.convert("bad", None, None)
|
||||
|
||||
|
||||
class TestRequestType:
|
||||
"""Tests for _RequestType."""
|
||||
|
||||
def test_convert_string(self) -> None:
|
||||
"""Test converting a JSON string to a request dictionary."""
|
||||
assert REQUEST_TYPE.convert('{"action": 1}', None, None) == {"action": 1}
|
||||
|
||||
def test_convert_invalid(self) -> None:
|
||||
"""Test that invalid values raise BadParameter."""
|
||||
with pytest.raises(Exception, match="Invalid JSON request"):
|
||||
REQUEST_TYPE.convert("bad", None, None)
|
||||
Reference in New Issue
Block a user