Compare commits

...

11 Commits

Author SHA1 Message Date
Daichi Narushima 9356d5dcdf Consolidate duplicated export and history streaming helpers (#29)
* Consolidate duplicated export and history streaming helpers.

Reduce repeated CLI export plumbing, shared per-symbol SQLite writes, and test mock setup without changing public behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bump version from 0.7.0 to 0.7.1.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 23:14:34 +09:00
Daichi Narushima 0fad55d609 Refactor MT5 constant parsing to delegate to pdmt5 >= 0.3.0 (#28)
* Refactor MT5 constant parsing to delegate to pdmt5 >= 0.3.0

Replace local TIMEFRAME_MAP, TICK_FLAG_MAP, and parser helpers with thin
compatibility wrappers around pdmt5. COPY_TICKS flags now use real MT5 values
(ALL=-1, INFO=1, TRADE=2). Click parameter types validate all inputs through
the wrappers. Update tests and docs to describe the pdmt5/mt5cli/mt5api layering.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* Fix timeframe defaults and COPY_TICKS flag defaults after pdmt5 migration

Use short timeframe aliases for default history collection and granularity
naming via pdmt5.get_timeframe_name. Set CLI/SDK default tick flags to ALL
(-1) instead of the legacy mt5cli-only value.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* Address CI lint failure and PR review feedback

Fix ruff import ordering in history.py. Use ALL string defaults for CLI tick
flags, isolate TICK_FLAG_MAP as a dict snapshot, derive flag names from pdmt5,
reuse TIMEFRAME_NAMES for default history timeframes, and add tests for prefix
stripping and TIMEFRAME_ key filtering.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* Bump version to 0.7.0

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>
2026-06-11 23:22:50 +09:00
dceoy d654b82f9d Bump version from 0.6.0 to 0.6.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 19:36:34 +09:00
Daichi Narushima b5e82e71c7 Add trading session helpers and extend ThrottledHistoryUpdater (#25)
* Add trading session helpers and extend ThrottledHistoryUpdater

Introduce mt5cli.trading with mt5_trading_session() for Mt5TradingClient
lifecycle management and reusable operational helpers for position-side
detection, margin/volume sizing, and protective order price derivation.

Extend ThrottledHistoryUpdater to validate inputs before updates and to
optionally suppress ValueError, OSError, and missing-method errors without
advancing the throttle timestamp.

Export the new helpers from mt5cli.__init__, add unit tests with mocked
clients, and document migration guidance for downstream projects such as
mteor.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* Narrow ThrottledHistoryUpdater suppress_errors handling (#27)

* Narrow ThrottledHistoryUpdater suppress_errors for MT5 capability only

Remove broad AttributeError/TypeError handling from recoverable errors.
Add _is_mt5_client_capability_error() to detect missing history API methods
or non-callable client attributes by message and attribute name.

Generic AttributeError/TypeError values always propagate even when
suppress_errors=True. Update docs and tests accordingly.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* Detect non-callable history client methods in suppress_errors

Address review feedback: when a history API attribute exists but is not
callable, Python raises a generic TypeError. Inspect the traceback for
mt5cli.history client call sites so these capability mismatches are still
suppressed without matching all TypeError values.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* Address PR review feedback on trading helpers

- Resolve history module path once at import time
- Only treat non-callable TypeErrors as capability errors at the raise site
- Validate SL/TP ratios in determine_order_limits
- Add tests for margin_free edge cases, body-raise shutdown, and internal TypeError propagation
- Clarify ThrottledHistoryUpdater suppress_errors docs
- Split README migration example into trading vs read-only history sessions

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* Tighten protective ratio validation and clamp negative margin_free

Add _require_protective_ratio enforcing 0 <= ratio < 1 for SL/TP limits so
a ratio of 1.0 cannot produce zero protective prices. Clamp negative
margin_free to 0.0 in calculate_margin_and_volume before sizing.

Add boundary and negative-margin tests; document constraints in trading API
docs.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>
2026-06-11 19:32:52 +09:00
Daichi Narushima 18df96872b Add closed-bar rate helpers (v0.6.0) (#26)
* Add closed-bar rate helpers and bump version to 0.6.0.

Expose drop_forming_rate_bar and multi-account collectors so downstream apps no longer need count+1 fetches and manual bar trimming.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bump pygments to 2.20.0 to fix CVE-2026-4539 ReDoS advisory.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback on closed-bar rate collection.

Validate count and start_pos before MT5 fetches, avoid redundant frame copies, clarify empty-series errors, and expand test coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Include symbol and timeframe in empty closed-rate error messages.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 02:30:48 +09:00
Daichi Narushima 5b1d54bfe9 Add resilient multi-account orchestration helpers (#22)
* Add SDK orchestration helpers for resilient multi-account collection

- collect_latest_rates_for_accounts_with_retries(): exponential-backoff
  retries around collect_latest_rates_for_accounts(), retrying only
  Mt5TradingError/Mt5RuntimeError and re-raising on exhaustion.
- resolve_account_spec()/resolve_account_specs() and
  substitute_env_placeholders(): merge explicit overrides over AccountSpec
  fields and expand ${ENV_VAR} placeholders, raising ValueError on missing
  variables.
- ThrottledHistoryUpdater: monotonic-clock throttled wrapper around
  update_history() with should_update()/update() and opt-in suppress_errors.
- load_rate_series_by_granularity(): rate-series loader keyed by
  (symbol | None, granularity_name).
- Export new APIs, add unit tests (100% coverage), and document in README
  and docs/api.

* chore: bump version from 0.5.1 to 0.5.3 (#24)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* fix: resolve leftover merge conflict markers in version files

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* fix: address PR review feedback on SDK orchestration helpers

- Use single-pass env substitution to avoid TOCTOU KeyError
- Apply backoff_base to all retry delays (backoff_base ** (attempt + 1))
- Preserve integer logins in resolve_account_spec; hide login in repr
- Fix docs examples (env ordering, while True loop, backoff comment)
- Parametrize suppress_errors tests for MT5 and SQLite errors

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>
2026-06-10 00:15:07 +09:00
Daichi Narushima ad9e513253 [codex] Guard dedup scopes by written columns (#23)
* Guard dedup scopes by written columns

* Address dedup scope review feedback

* Remove legacy dedup scope support

* Remove stale legacy descriptions

* chore: bump version from 0.5.1 to 0.5.2
2026-06-09 23:27:54 +09:00
Daichi Narushima 334f01b647 chore: bump version from 0.5.0 to 0.5.1 (#21) 2026-06-09 15:52:32 +09:00
Daichi Narushima 1b69e8f08e Add generic MT5 rate-loading SDK APIs for downstream reuse (#20) 2026-06-09 15:37:24 +09:00
Daichi Narushima 9957b0a1de [codex] Add generic MT5 SDK and SQLite rate loader (#19)
* Add generic MT5 SDK and SQLite rate loader

* Fix MT5 latest rates connection reuse

* Make MT5 summary export safe

* Address PR review feedback for SDK and SQLite rate loader.

Reuse parse_sqlite_timestamp for rate time parsing, document empty-table
errors, tighten tests, and align docs with require_existing=True.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 11:27:29 +09:00
Daichi Narushima b2bb2ad0a0 Add rate view resolution and downstream SDK helpers (#18)
* Add public helpers to resolve rate compatibility view names.

Expose resolve_rate_view_name and resolve_rate_view_names in mt5cli.history so consumers can derive mt5cli-managed SQLite view names from stored rates metadata without reimplementing the naming rules.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add reusable export, tick-window, and margin helpers for downstream tools.

Expose SQLite append/dedup export, recent tick retrieval, and minimum margin
summary through the SDK and CLI so projects like mteor can depend on mt5cli
instead of duplicating MT5 data plumbing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bump version to 0.4.3.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback for rate view resolution and SDK helpers.

Harden SQLite read-only connections, tighten view discovery, improve recent_ticks
fetch efficiency, default SQLite export to append, and expand tests and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix read-only SQLite URI construction on Windows.

Use Path.as_uri() so encoded file URIs work cross-platform with mode=ro.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 03:29:03 +09:00
21 changed files with 5866 additions and 313 deletions
+116 -22
View File
@@ -6,6 +6,12 @@ Command-line tool for exporting MetaTrader 5 data to CSV, JSON, Parquet, and SQL
Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5. Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5.
## Architecture
- **pdmt5** — canonical MT5 client, DataFrame/trading primitives, and MT5 constant parsing (`TIMEFRAME_*`, `COPY_TICKS_*`, order types).
- **mt5cli** — CLI commands, CSV/JSON/Parquet/SQLite export, SQLite history collection, rate views, and local batch/automation SDK helpers built on pdmt5.
- **mt5api** — sibling HTTP adapter for remote MT5 access; not a dependency of mt5cli.
## Features ## Features
- **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats - **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats
@@ -13,6 +19,7 @@ Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data han
- **Comprehensive data access**: Rates, ticks, account info, symbols, orders, positions, and trading history - **Comprehensive data access**: Rates, ticks, account info, symbols, orders, positions, and trading history
- **Flexible timeframes**: Named timeframes (M1, H1, D1, etc.) and numeric values - **Flexible timeframes**: Named timeframes (M1, H1, D1, etc.) and numeric values
- **Connection management**: Optional credentials, server, and timeout configuration - **Connection management**: Optional credentials, server, and timeout configuration
- **SQLite rate loading**: Load mt5cli-managed rate tables/views for offline workflows
## Installation ## Installation
@@ -50,28 +57,33 @@ python -m mt5cli -o account.csv account-info
## Commands ## Commands
| Command | Description | | Command | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------ | | ---------------------- | ------------------------------------------------------------------------------------------------------------ |
| `rates-from` | Export rates from a start date | | `rates-from` | Export rates from a start date |
| `rates-from-pos` | Export rates from a start position | | `rates-from-pos` | Export rates from a start position |
| `rates-range` | Export rates for a date range | | `latest-rates` | Export latest rates from a start position |
| `ticks-from` | Export ticks from a start date | | `rates-range` | Export rates for a date range |
| `ticks-range` | Export ticks for a date range | | `ticks-from` | Export ticks from a start date |
| `account-info` | Export account information | | `ticks-range` | Export ticks for a date range |
| `terminal-info` | Export terminal information | | `ticks-recent` | Export ticks from a recent trailing window |
| `version` | Export MetaTrader 5 version information | | `account-info` | Export account information |
| `last-error` | Export the last error information | | `terminal-info` | Export terminal information |
| `symbols` | Export symbol list | | `version` | Export MetaTrader 5 version information |
| `symbol-info` | Export symbol details | | `last-error` | Export the last error information |
| `symbol-info-tick` | Export the last tick for a symbol | | `symbols` | Export symbol list |
| `market-book` | Export market depth (order book) | | `symbol-info` | Export symbol details |
| `orders` | Export active orders | | `symbol-info-tick` | Export the last tick for a symbol |
| `positions` | Export open positions | | `minimum-margins` | Export minimum-volume buy and sell margin requirements |
| `history-orders` | Export historical orders | | `market-book` | Export market depth (order book) |
| `history-deals` | Export historical deals | | `orders` | Export active orders |
| `order-check` | Check funds sufficiency for a trade request | | `positions` | Export open positions |
| `order-send` | Send a trade request to the trade server (`--yes` required) | | `history-orders` | Export historical orders |
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database | | `history-deals` | Export historical deals |
| `recent-history-deals` | Export historical deals from a recent trailing window |
| `mt5-summary` | Export terminal/account status summary |
| `order-check` | Check funds sufficiency for a trade request |
| `order-send` | Send a trade request to the trade server (`--yes` required) |
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
Use `order-check` to validate a request payload before running `order-send --yes`. Use `order-check` to validate a request payload before running `order-send --yes`.
@@ -127,6 +139,31 @@ update_history_with_config(
- **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`. - **`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. - **`rates` table**: normalized storage with `symbol` and `timeframe` columns.
- **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize. - **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize.
- **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
- **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
- **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
- **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping. For long-running pollers, `collect_latest_rates_for_accounts_with_retries()` adds bounded exponential backoff that retries only `pdmt5.Mt5TradingError` / `pdmt5.Mt5RuntimeError` and re-raises once `retry_count` is exhausted.
- **Latest closed bars**: use `collect_latest_closed_rates_for_accounts()` when downstream logic must exclude the still-forming current bar. It fetches `count + 1` bars at `start_pos=0`, drops the last row with `drop_forming_rate_bar()`, and validates each series is non-empty. `collect_latest_closed_rates_by_granularity()` returns the same data keyed by `(symbol, granularity_name)` such as `("EURUSD", "M1")`.
```python
from mt5cli import AccountSpec, collect_latest_closed_rates_by_granularity
rates = collect_latest_closed_rates_by_granularity(
[AccountSpec(symbols=["EURUSD", "GBPUSD"], login=12345)],
["M1", "H1"],
count=500,
retry_count=3,
)
eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
```
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate).
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. The read-only `mt5_session()` / `Mt5CliClient` SDK is unchanged.
- **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `Mt5CliClient` that shuts down on exit.
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
- **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries.
## Requirements ## Requirements
@@ -134,6 +171,63 @@ update_history_with_config(
- Windows OS (MetaTrader 5 requirement) - Windows OS (MetaTrader 5 requirement)
- MetaTrader 5 platform installed - MetaTrader 5 platform installed
### Migration note for mteor
Replace local MT5 lifecycle and trading helper code with mt5cli imports:
```python
# Before (local mteor helpers)
# with local_mt5_trading_session(config) as client:
# side = local_detect_position_side(client, symbol)
# sizing = local_calculate_margin_and_volume(client, symbol, unit_ratio, preserved_ratio)
# limits = local_determine_order_limits(client, symbol, side, sl_ratio, tp_ratio)
# After (mt5cli shared layer)
from pdmt5 import Mt5Config
from mt5cli import (
calculate_margin_and_volume,
detect_position_side,
determine_order_limits,
mt5_trading_session,
)
with mt5_trading_session(
Mt5Config(path=terminal_path, login=login), retry_count=2
) as client:
side = detect_position_side(client, symbol)
sizing = calculate_margin_and_volume(
client, symbol, unit_margin_ratio=0.5, preserved_margin_ratio=0.2
)
if side is not None:
limits = determine_order_limits(
client,
symbol,
side,
stop_loss_limit_ratio=0.01,
take_profit_limit_ratio=0.02,
)
```
Throttled history updates use a separate read-only session:
```python
from pdmt5 import Mt5Config, Mt5DataClient
from mt5cli import ThrottledHistoryUpdater
updater = ThrottledHistoryUpdater(
output="history.db", interval_seconds=60, suppress_errors=True
)
client = Mt5DataClient(config=Mt5Config(login=login))
client.initialize_and_login_mt5()
try:
updater.update(client, ["EURUSD"])
finally:
client.shutdown()
```
Read-only collectors can keep using `mt5_session()` and `Mt5CliClient` without changes.
## Development ## Development
```bash ```bash
+98
View File
@@ -129,3 +129,101 @@ when required columns are missing.
The `update_history` SDK path uses the same base tables and optional The `update_history` SDK path uses the same base tables and optional
`cash_events` / `positions_reconstructed` views. It additionally maintains `cash_events` / `positions_reconstructed` views. It additionally maintains
`rate_<symbol>__<timeframe>` compatibility views when `create_rate_views=True`. `rate_<symbol>__<timeframe>` compatibility views when `create_rate_views=True`.
### Rate view resolution
Downstream tools can resolve mt5cli-managed compatibility view names from an
existing SQLite history database without creating files or guessing naming
schemes:
```python
from pathlib import Path
from mt5cli.history import resolve_rate_view_name, resolve_rate_view_names
# Single symbol and granularity
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1")
# Batch resolution in row-major order
views = resolve_rate_view_names(
Path("history.db"),
["EURUSD", "GBPUSD"],
["M1", "H1"],
)
```
Resolution rules:
- Returns `rate_<symbol>__<timeframe>` when a symbol stores one timeframe.
- Returns `rate_<symbol>__<granularity>_<timeframe>` when multiple timeframes
are stored for the same symbol.
- When multiple naming candidates apply, prefers an existing managed
`rate_*__*` view from the candidate list.
- Falls back to single-timeframe naming when the database path is missing or
`rates` metadata is unavailable.
- Pass `require_existing=True` to raise `ValueError` instead of returning a
best-guess name when the database or view is missing.
- Accepts either a SQLite path or an open `sqlite3.Connection`.
### Rate data loading
Use `load_rate_data()` to load a table or view from a SQLite path, or
`load_rate_data_from_connection()` when you already have a connection:
```python
from pathlib import Path
from mt5cli import load_rate_data
from mt5cli.history import resolve_rate_view_name
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
rates = load_rate_data(Path("history.db"), view, count=1000)
```
The loader accepts close-based OHLC rate data or tick-like bid/ask data. It
validates that `time` exists, parses timestamps with pandas, and returns a
DataFrame indexed by ascending `DatetimeIndex` named `time`.
### Multi-series rate loading
For loading many rate series at once, build neutral `RateTarget` pairs and load
them from SQLite in one call. View names are resolved via the same
compatibility-view rules, or you can pass `explicit_tables` to bypass resolution:
```python
from pathlib import Path
from mt5cli import build_rate_targets, load_rate_series_from_sqlite
targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
series = load_rate_series_from_sqlite(Path("history.db"), targets, count=1000)
frame = series["EURUSD", 1] # keyed by (symbol, integer timeframe)
```
- `build_rate_targets()` returns `RateTarget(symbol, timeframe)` pairs in
row-major order, normalizing timeframe names such as `"M1"` to their integer
values; set `allow_missing_symbol=True` to address series solely by
`explicit_tables` (targets carry `symbol=None`).
- `resolve_rate_tables()` maps targets to table or view names and validates that
any `explicit_tables` count matches the target count. Pass
`require_existing=True` to raise `ValueError` instead of returning a
best-guess name when the database or managed view is missing. When
`explicit_tables` is provided, names are returned as-is and
`require_existing` is ignored.
- `load_rate_series_from_sqlite()` returns a mapping keyed by
`(symbol, integer timeframe)`. Unless `explicit_tables` is supplied, it
requires existing managed `rate_*` compatibility views and raises
`ValueError` when they are missing. Duplicate `(symbol, timeframe)` targets
are rejected.
- `load_rate_series_by_granularity()` is a thin wrapper that builds the targets,
loads the series, and rekeys the result by granularity name to avoid
converting integer timeframes downstream:
```python
from mt5cli import load_rate_series_by_granularity
series = load_rate_series_by_granularity(
"history.db", ["EURUSD"], ["M1", "H1"], count=1000
)
frame = series["EURUSD", "M1"] # keyed by (symbol | None, granularity_name)
```
+27 -2
View File
@@ -18,6 +18,10 @@ Utility module providing constants, enums, Click parameter types, and helper fun
Programmatic SDK for read-only MetaTrader 5 data collection. Returns pandas DataFrames and provides `collect_history` for SQLite bulk collection. Programmatic SDK for read-only MetaTrader 5 data collection. Returns pandas DataFrames and provides `collect_history` for SQLite bulk collection.
### [Trading](trading.md)
Trading-capable session management and operational helpers built on `pdmt5.Mt5TradingClient`. Complements the read-only SDK without changing existing `Mt5CliClient` behavior.
### [History Collection (SQLite)](history.md) ### [History Collection (SQLite)](history.md)
SQLite storage helpers for the `collect-history` command schema, incremental updates, deduplication, indexes, and optional views. SQLite storage helpers for the `collect-history` command schema, incremental updates, deduplication, indexes, and optional views.
@@ -28,8 +32,9 @@ The package follows a simple architecture built on top of pdmt5:
1. **CLI Layer** (`cli.py`): Typer application with subcommands that delegate to the SDK and export results. 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. 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. 3. **Trading Layer** (`trading.py`): Trading-capable sessions and operational helpers on `Mt5TradingClient`.
4. **Data Layer** (via `pdmt5`): Uses `Mt5DataClient` and `Mt5Config` from the pdmt5 package for all MetaTrader 5 data access. 4. **Utils Layer** (`utils.py`): Constants, enums, custom Click parameter types, parsing helpers, and format detection/export utilities.
5. **Data Layer** (via `pdmt5`): Uses `Mt5DataClient`, `Mt5TradingClient`, and `Mt5Config` from the pdmt5 package for MetaTrader 5 access.
## Usage Guidelines ## Usage Guidelines
@@ -65,12 +70,18 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from mt5cli import ( from mt5cli import (
Dataset,
IfExists,
Mt5CliClient, Mt5CliClient,
collect_history, collect_history,
copy_rates_range, copy_rates_range,
detect_format, detect_format,
export_dataframe, export_dataframe,
export_dataframe_to_sqlite,
minimum_margins,
recent_ticks,
) )
from mt5cli.history import resolve_rate_view_name
# Fetch rates programmatically # Fetch rates programmatically
rates = copy_rates_range( rates = copy_rates_range(
@@ -86,6 +97,20 @@ fmt = detect_format(Path("output.parquet")) # Returns "parquet"
# Export a DataFrame # Export a DataFrame
export_dataframe(rates, Path("output.csv"), "csv") export_dataframe(rates, Path("output.csv"), "csv")
# Append to SQLite with deduplication
export_dataframe_to_sqlite(
rates,
Path("history.db"),
"rates",
if_exists=IfExists.APPEND,
deduplicate_on=("symbol", "timeframe", "time"),
)
# Resolve rate compatibility views and fetch recent ticks
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1")
ticks = recent_ticks("EURUSD", seconds=300)
margins = minimum_margins("EURUSD")
# Collect history into SQLite # Collect history into SQLite
collect_history( collect_history(
Path("history.db"), Path("history.db"),
+111
View File
@@ -1,3 +1,114 @@
# SDK Module # SDK Module
::: mt5cli.sdk ::: mt5cli.sdk
## Resilient multi-account orchestration
The SDK ships strategy-agnostic helpers for building long-running collectors on
top of the read-only client. None of them depend on a particular trading
application.
### Retrying transient rate collection
`collect_latest_rates_for_accounts_with_retries()` wraps
`collect_latest_rates_for_accounts()` with bounded exponential backoff. Only
`pdmt5.Mt5TradingError` and `pdmt5.Mt5RuntimeError` are retried; the final
failure is re-raised once `retry_count` is exhausted.
```python
from mt5cli import AccountSpec, collect_latest_rates_for_accounts_with_retries
accounts = [AccountSpec(symbols=["EURUSD"], login=12345)]
rates = collect_latest_rates_for_accounts_with_retries(
accounts,
["M1", "H1"],
count=500,
retry_count=3,
backoff_base=2, # sleeps 2s, 4s, 8s between attempts
)
```
### Latest closed rate bars
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
row. `collect_latest_closed_rates_for_accounts()` fetches `count + 1` bars,
drops that row with `drop_forming_rate_bar()`, and validates each series is
non-empty. Use `collect_latest_closed_rates_by_granularity()` when callers
prefer keys such as `("EURUSD", "M1")` instead of integer timeframes.
```python
from mt5cli import AccountSpec, collect_latest_closed_rates_by_granularity
rates = collect_latest_closed_rates_by_granularity(
[AccountSpec(symbols=["EURUSD"], login=12345)],
["M1", "H1"],
count=500,
retry_count=3,
)
closed_m1 = rates["EURUSD", "M1"]
```
### Resolving credentials and `${ENV_VAR}` placeholders
`resolve_account_spec()` / `resolve_account_specs()` merge explicit override
values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders, keeping
secrets out of plan/config files. A missing environment variable raises
`ValueError`.
```python
import os
from mt5cli import AccountSpec, resolve_account_specs
os.environ["MT5_LOGIN"] = "12345"
os.environ["MT5_PASSWORD"] = "secret"
accounts = [
AccountSpec(symbols=["EURUSD"], login="${MT5_LOGIN}", password="${MT5_PASSWORD}")
]
resolved = resolve_account_specs(accounts, server="Broker-Demo")
# resolved[0].login == "12345", resolved[0].server == "Broker-Demo"
```
### Throttled incremental history updates
`ThrottledHistoryUpdater` wraps `update_history()` with a minimum interval
between successful runs (using a monotonic clock), so an application loop can
call it every iteration without over-fetching.
```python
from pdmt5 import Mt5Config, Mt5DataClient
from mt5cli import Dataset, ThrottledHistoryUpdater
updater = ThrottledHistoryUpdater(
output="history.db",
datasets={Dataset.rates},
timeframes=["M1"],
interval_seconds=60, # <= 0 updates on every call
)
client = Mt5DataClient(config=Mt5Config(login=12345))
client.initialize_and_login_mt5()
try:
while True:
updater.update(client, ["EURUSD", "GBPUSD"]) # no-op until 60s elapse
# ... do other work; break when shutting down ...
finally:
client.shutdown()
```
By default recoverable errors (`Mt5TradingError`, `Mt5RuntimeError`,
`sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability
`AttributeError` / `TypeError` for history API methods) propagate so the caller
controls logging; pass `suppress_errors=True` to swallow them and return
`False` without advancing the throttle. Other `AttributeError` / `TypeError`
values always propagate. Input validation (`_resolve_update_history_request`)
runs before any MT5 or SQLite calls, but when `suppress_errors=True` the
resulting `ValueError` is suppressed along with other recoverable errors.
## Trading-capable sessions
For order placement and trading calculations, use the dedicated
[Trading module](trading.md). The read-only `Mt5CliClient` and `mt5_session()`
helpers in this module are unchanged.
+70
View File
@@ -0,0 +1,70 @@
# Trading Module
::: mt5cli.trading
## Trading-capable MT5 sessions
`mt5_trading_session()` complements the read-only `mt5_session()` helper in
`sdk.py`. It yields a connected `pdmt5.Mt5TradingClient`, uses
`Mt5Config.path` to launch the terminal when configured, and always calls
`shutdown()` on exit.
```python
from pdmt5 import Mt5Config
from mt5cli import mt5_trading_session
with mt5_trading_session(
Mt5Config(path=r"C:\Program Files\MetaTrader 5\terminal64.exe", login=12345),
retry_count=2,
) as client:
positions = client.positions_get_as_df(symbol="EURUSD")
```
The read-only `Mt5CliClient` / `mt5_session()` API is unchanged.
## Operational trading helpers
These helpers are strategy-agnostic and do not depend on signal detection,
betting logic, or scheduling code in downstream applications.
```python
from mt5cli import (
calculate_margin_and_volume,
detect_position_side,
determine_order_limits,
)
side = detect_position_side(client, "EURUSD")
sizing = calculate_margin_and_volume(
client,
"EURUSD",
unit_margin_ratio=0.5,
preserved_margin_ratio=0.2,
)
limits = determine_order_limits(
client,
"EURUSD",
side="long",
stop_loss_limit_ratio=0.01,
take_profit_limit_ratio=0.02,
)
```
Protective ratios must satisfy `0 <= ratio < 1`; `0` omits that level.
`calculate_margin_and_volume()` clamps negative `margin_free` to `0.0`
before sizing.
## Migration from mteor-local helpers
| mteor-local concern | mt5cli replacement |
| -------------------------------------------------------- | ----------------------------------------------- |
| Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
| Local position-side detection | `detect_position_side()` |
| Local margin/volume sizing | `calculate_margin_and_volume()` |
| Local SL/TP price derivation | `determine_order_limits()` |
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
Keep read-only data collection on `mt5_session()` / `Mt5CliClient`; use
`mt5_trading_session()` only where order placement or trading calculations are
required.
+49 -14
View File
@@ -6,6 +6,12 @@ Command-line tool for MetaTrader 5 data export.
mt5cli is a CLI application that exports MetaTrader 5 trading data to multiple file formats. It is built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5. mt5cli is a CLI application that exports MetaTrader 5 trading data to multiple file formats. It is built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5.
## Architecture
- **pdmt5** — canonical MT5 client, DataFrame/trading primitives, and MT5 constant parsing (`TIMEFRAME_*`, `COPY_TICKS_*`, order types).
- **mt5cli** — CLI commands, CSV/JSON/Parquet/SQLite export, SQLite history collection, rate views, and local batch/automation SDK helpers built on pdmt5.
- **mt5api** — sibling HTTP adapter for remote MT5 access; not a dependency of mt5cli.
## Features ## Features
- **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats - **Multi-format export**: CSV, JSON, Parquet, and SQLite3 output formats
@@ -13,6 +19,7 @@ mt5cli is a CLI application that exports MetaTrader 5 trading data to multiple f
- **Comprehensive data access**: Rates, ticks, account info, symbols, orders, positions, and trading history - **Comprehensive data access**: Rates, ticks, account info, symbols, orders, positions, and trading history
- **Flexible timeframes**: Named timeframes (M1, H1, D1, etc.) and numeric values - **Flexible timeframes**: Named timeframes (M1, H1, D1, etc.) and numeric values
- **Connection management**: Optional credentials, server, and timeout configuration - **Connection management**: Optional credentials, server, and timeout configuration
- **SQLite rate loading**: Load mt5cli-managed rate tables/views for offline workflows
## Installation ## Installation
@@ -22,13 +29,23 @@ pip install mt5cli
## Programmatic usage / SDK usage ## 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. 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` or `export_dataframe_to_sqlite` when you need to persist results.
```python ```python
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from mt5cli import Mt5CliClient, collect_history, copy_rates_range, export_dataframe from mt5cli import (
Mt5CliClient,
collect_history,
copy_rates_range,
export_dataframe,
export_dataframe_to_sqlite,
load_rate_data,
minimum_margins,
recent_ticks,
)
from mt5cli.history import resolve_rate_view_name
# One-off fetch with module-level helpers # One-off fetch with module-level helpers
rates = copy_rates_range( rates = copy_rates_range(
@@ -39,10 +56,21 @@ rates = copy_rates_range(
) )
export_dataframe(rates, Path("rates.csv"), "csv") export_dataframe(rates, Path("rates.csv"), "csv")
# Resolve SQLite rate compatibility views for downstream tools
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
offline_rates = load_rate_data(Path("history.db"), view, count=1000)
# Recent tick window and minimum margin summary
ticks = recent_ticks("EURUSD", seconds=300)
margins = minimum_margins("EURUSD")
# Reuse one MT5 connection for multiple calls # Reuse one MT5 connection for multiple calls
with Mt5CliClient(login=12345, password="secret", server="Broker-Demo") as client: with Mt5CliClient(login=12345, password="secret", server="Broker-Demo") as client:
account = client.account_info() account = client.account_info()
positions = client.positions() positions = client.positions()
latest = client.latest_rates("EURUSD", "M1", count=100)
summary = client.mt5_summary()
summary_table = client.mt5_summary_as_df()
# Bulk SQLite collection (same behavior as the collect-history CLI command) # Bulk SQLite collection (same behavior as the collect-history CLI command)
collect_history( collect_history(
@@ -58,6 +86,8 @@ collect_history(
Timeframes, tick flags, and ISO 8601 date strings are accepted wherever noted in the SDK API. Timeframes, tick flags, and ISO 8601 date strings are accepted wherever noted in the SDK API.
`Mt5CliClient.mt5_summary()` returns the SDK structured form as plain nested Python values. Use `Mt5CliClient.mt5_summary_as_df()` when you need a one-row DataFrame for export. The `mt5-summary` CLI command uses this tabular form, so nested terminal/account fields are JSON-encoded strings that are safe for CSV, JSON, Parquet, and SQLite output.
## Quick Start ## Quick Start
```bash ```bash
@@ -88,14 +118,16 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
| ---------------- | ---------------------------------- | | ---------------- | ---------------------------------- |
| `rates-from` | Export rates from a start date | | `rates-from` | Export rates from a start date |
| `rates-from-pos` | Export rates from a start position | | `rates-from-pos` | Export rates from a start position |
| `latest-rates` | Export latest rates |
| `rates-range` | Export rates for a date range | | `rates-range` | Export rates for a date range |
### Ticks ### Ticks
| Command | Description | | Command | Description |
| ------------- | ------------------------------ | | -------------- | ----------------------------------- |
| `ticks-from` | Export ticks from a start date | | `ticks-from` | Export ticks from a start date |
| `ticks-range` | Export ticks for a date range | | `ticks-range` | Export ticks for a date range |
| `ticks-recent` | Export ticks from a trailing window |
### Information ### Information
@@ -108,18 +140,21 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
| `symbols` | Export symbol list | | `symbols` | Export symbol list |
| `symbol-info` | Export symbol details | | `symbol-info` | Export symbol details |
| `symbol-info-tick` | Export the last tick for a symbol | | `symbol-info-tick` | Export the last tick for a symbol |
| `minimum-margins` | Export minimum-volume margin summary |
| `market-book` | Export market depth (order book) | | `market-book` | Export market depth (order book) |
### Trading ### Trading
| Command | Description | | Command | Description |
| ---------------- | ----------------------------------------------------------- | | ---------------------- | ----------------------------------------------------------- |
| `orders` | Export active orders | | `orders` | Export active orders |
| `positions` | Export open positions | | `positions` | Export open positions |
| `history-orders` | Export historical orders | | `history-orders` | Export historical orders |
| `history-deals` | Export historical deals | | `history-deals` | Export historical deals |
| `order-check` | Check funds sufficiency for a trade request | | `recent-history-deals` | Export historical deals from a trailing window |
| `order-send` | Send a trade request to the trade server (`--yes` required) | | `mt5-summary` | Export terminal/account status summary |
| `order-check` | Check funds sufficiency for a trade request |
| `order-send` | Send a trade request to the trade server (`--yes` required) |
Use `order-check` to validate a request payload before running `order-send --yes`. Use `order-check` to validate a request payload before running `order-send --yes`.
+1
View File
@@ -58,6 +58,7 @@ nav:
- Overview: api/index.md - Overview: api/index.md
- CLI: api/cli.md - CLI: api/cli.md
- SDK: api/sdk.md - SDK: api/sdk.md
- Trading: api/trading.md
- History Collection (SQLite): api/history.md - History Collection (SQLite): api/history.md
- Utils: api/utils.md - Utils: api/utils.md
+92 -1
View File
@@ -2,11 +2,34 @@
from importlib.metadata import version from importlib.metadata import version
from .history import (
RateTarget,
build_rate_targets,
build_rate_view_name,
drop_forming_rate_bar,
load_rate_data,
load_rate_data_from_connection,
load_rate_series_by_granularity,
load_rate_series_from_sqlite,
resolve_history_datasets,
resolve_history_tick_flags,
resolve_history_timeframes,
resolve_rate_tables,
resolve_rate_view_name,
resolve_rate_view_names,
)
from .sdk import ( from .sdk import (
AccountSpec,
Mt5CliClient, Mt5CliClient,
ThrottledHistoryUpdater,
account_info, account_info,
build_config, build_config,
collect_history, collect_history,
collect_latest_closed_rates_by_granularity,
collect_latest_closed_rates_for_accounts,
collect_latest_rates,
collect_latest_rates_for_accounts,
collect_latest_rates_for_accounts_with_retries,
copy_rates_from, copy_rates_from,
copy_rates_from_pos, copy_rates_from_pos,
copy_rates_range, copy_rates_range,
@@ -15,9 +38,19 @@ from .sdk import (
history_deals, history_deals,
history_orders, history_orders,
last_error, last_error,
latest_rates,
market_book, market_book,
minimum_margins,
mt5_session,
mt5_summary,
mt5_summary_as_df,
orders, orders,
positions, positions,
recent_history_deals,
recent_ticks,
resolve_account_spec,
resolve_account_specs,
substitute_env_placeholders,
symbol_info, symbol_info,
symbol_info_tick, symbol_info_tick,
symbols, symbols,
@@ -28,31 +61,89 @@ from .sdk import (
from .sdk import ( from .sdk import (
version as mt5_version, version as mt5_version,
) )
from .utils import Dataset, IfExists, detect_format, export_dataframe from .trading import (
calculate_margin_and_volume,
detect_position_side,
determine_order_limits,
mt5_trading_session,
)
from .utils import (
TICK_FLAG_MAP,
TIMEFRAME_MAP,
Dataset,
IfExists,
detect_format,
export_dataframe,
export_dataframe_to_sqlite,
parse_datetime,
parse_tick_flags,
parse_timeframe,
)
__version__ = version(__package__) if __package__ else None __version__ = version(__package__) if __package__ else None
__all__ = [ __all__ = [
"TICK_FLAG_MAP",
"TIMEFRAME_MAP",
"AccountSpec",
"Dataset", "Dataset",
"IfExists", "IfExists",
"Mt5CliClient", "Mt5CliClient",
"RateTarget",
"ThrottledHistoryUpdater",
"account_info", "account_info",
"build_config", "build_config",
"build_rate_targets",
"build_rate_view_name",
"calculate_margin_and_volume",
"collect_history", "collect_history",
"collect_latest_closed_rates_by_granularity",
"collect_latest_closed_rates_for_accounts",
"collect_latest_rates",
"collect_latest_rates_for_accounts",
"collect_latest_rates_for_accounts_with_retries",
"copy_rates_from", "copy_rates_from",
"copy_rates_from_pos", "copy_rates_from_pos",
"copy_rates_range", "copy_rates_range",
"copy_ticks_from", "copy_ticks_from",
"copy_ticks_range", "copy_ticks_range",
"detect_format", "detect_format",
"detect_position_side",
"determine_order_limits",
"drop_forming_rate_bar",
"export_dataframe", "export_dataframe",
"export_dataframe_to_sqlite",
"history_deals", "history_deals",
"history_orders", "history_orders",
"last_error", "last_error",
"latest_rates",
"load_rate_data",
"load_rate_data_from_connection",
"load_rate_series_by_granularity",
"load_rate_series_from_sqlite",
"market_book", "market_book",
"minimum_margins",
"mt5_session",
"mt5_summary",
"mt5_summary_as_df",
"mt5_trading_session",
"mt5_version", "mt5_version",
"orders", "orders",
"parse_datetime",
"parse_tick_flags",
"parse_timeframe",
"positions", "positions",
"recent_history_deals",
"recent_ticks",
"resolve_account_spec",
"resolve_account_specs",
"resolve_history_datasets",
"resolve_history_tick_flags",
"resolve_history_timeframes",
"resolve_rate_tables",
"resolve_rate_view_name",
"resolve_rate_view_names",
"substitute_env_placeholders",
"symbol_info", "symbol_info",
"symbol_info_tick", "symbol_info_tick",
"symbols", "symbols",
+145 -40
View File
@@ -96,6 +96,15 @@ def _sdk_client(ctx: typer.Context) -> sdk.Mt5CliClient:
return sdk.Mt5CliClient(config=export_ctx.config) return sdk.Mt5CliClient(config=export_ctx.config)
def _export_command(
ctx: typer.Context,
fetch_fn: Callable[[sdk.Mt5CliClient], pd.DataFrame],
) -> None:
"""Create an SDK client, fetch a DataFrame, and export it."""
client = _sdk_client(ctx)
_execute_export(ctx, lambda: fetch_fn(client))
@app.callback() @app.callback()
def _callback( # pyright: ignore[reportUnusedFunction] def _callback( # pyright: ignore[reportUnusedFunction]
ctx: typer.Context, ctx: typer.Context,
@@ -193,10 +202,9 @@ def rates_from(
count: Annotated[int, typer.Option(help="Number of records.")], count: Annotated[int, typer.Option(help="Number of records.")],
) -> None: ) -> None:
"""Export rates from a start date.""" """Export rates from a start date."""
client = _sdk_client(ctx) _export_command(
_execute_export(
ctx, ctx,
lambda: client.copy_rates_from(symbol, timeframe, date_from, count), lambda client: client.copy_rates_from(symbol, timeframe, date_from, count),
) )
@@ -215,10 +223,43 @@ def rates_from_pos(
count: Annotated[int, typer.Option(help="Number of records.")], count: Annotated[int, typer.Option(help="Number of records.")],
) -> None: ) -> None:
"""Export rates from a start position.""" """Export rates from a start position."""
client = _sdk_client(ctx) _export_command(
_execute_export(
ctx, ctx,
lambda: client.copy_rates_from_pos(symbol, timeframe, start_pos, count), lambda client: client.copy_rates_from_pos(
symbol,
timeframe,
start_pos,
count,
),
)
@app.command()
def latest_rates(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
timeframe: Annotated[
int,
typer.Option(
click_type=TIMEFRAME_TYPE,
help="Timeframe.",
),
],
count: Annotated[int, typer.Option(help="Number of records.")],
start_pos: Annotated[
int,
typer.Option(help="Start position (0 = current bar)."),
] = 0,
) -> None:
"""Export latest rates from a start position."""
_export_command(
ctx,
lambda client: client.latest_rates(
symbol,
timeframe,
count,
start_pos=start_pos,
),
) )
@@ -243,10 +284,9 @@ def rates_range(
], ],
) -> None: ) -> None:
"""Export rates for a date range.""" """Export rates for a date range."""
client = _sdk_client(ctx) _export_command(
_execute_export(
ctx, ctx,
lambda: client.copy_rates_range(symbol, timeframe, date_from, date_to), lambda client: client.copy_rates_range(symbol, timeframe, date_from, date_to),
) )
@@ -268,10 +308,9 @@ def ticks_from(
], ],
) -> None: ) -> None:
"""Export ticks from a start date.""" """Export ticks from a start date."""
client = _sdk_client(ctx) _export_command(
_execute_export(
ctx, ctx,
lambda: client.copy_ticks_from(symbol, date_from, count, flags), lambda client: client.copy_ticks_from(symbol, date_from, count, flags),
) )
@@ -293,23 +332,59 @@ def ticks_range(
], ],
) -> None: ) -> None:
"""Export ticks for a date range.""" """Export ticks for a date range."""
client = _sdk_client(ctx) _export_command(
_execute_export(
ctx, ctx,
lambda: client.copy_ticks_range(symbol, date_from, date_to, flags), lambda client: client.copy_ticks_range(symbol, date_from, date_to, flags),
)
@app.command()
def ticks_recent(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
seconds: Annotated[
float,
typer.Option(help="Lookback window in seconds."),
],
date_to: Annotated[
datetime | None,
typer.Option(click_type=DATETIME_TYPE, help="Window end date."),
] = None,
count: Annotated[
int,
typer.Option(help="Maximum number of ticks to return."),
] = 10000,
flags: Annotated[
int,
typer.Option(
click_type=TICK_FLAGS_TYPE,
help="Tick flags (ALL, INFO, TRADE, or integer).",
),
] = "ALL", # pyright: ignore[reportArgumentType]
) -> None:
"""Export ticks from a recent time window."""
_export_command(
ctx,
lambda client: client.recent_ticks(
symbol,
seconds,
date_to=date_to,
count=count,
flags=flags,
),
) )
@app.command() @app.command()
def account_info(ctx: typer.Context) -> None: def account_info(ctx: typer.Context) -> None:
"""Export account information.""" """Export account information."""
_execute_export(ctx, _sdk_client(ctx).account_info) _export_command(ctx, lambda client: client.account_info())
@app.command() @app.command()
def terminal_info(ctx: typer.Context) -> None: def terminal_info(ctx: typer.Context) -> None:
"""Export terminal information.""" """Export terminal information."""
_execute_export(ctx, _sdk_client(ctx).terminal_info) _export_command(ctx, lambda client: client.terminal_info())
@app.command() @app.command()
@@ -321,8 +396,7 @@ def symbols(
] = None, ] = None,
) -> None: ) -> None:
"""Export symbol list.""" """Export symbol list."""
client = _sdk_client(ctx) _export_command(ctx, lambda client: client.symbols(group=group))
_execute_export(ctx, lambda: client.symbols(group=group))
@app.command() @app.command()
@@ -331,8 +405,16 @@ def symbol_info(
symbol: Annotated[str, typer.Option(help="Symbol name.")], symbol: Annotated[str, typer.Option(help="Symbol name.")],
) -> None: ) -> None:
"""Export symbol details.""" """Export symbol details."""
client = _sdk_client(ctx) _export_command(ctx, lambda client: client.symbol_info(symbol))
_execute_export(ctx, lambda: client.symbol_info(symbol))
@app.command()
def minimum_margins(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
) -> None:
"""Export minimum-volume buy and sell margin requirements."""
_export_command(ctx, lambda client: client.minimum_margins(symbol))
@app.command() @app.command()
@@ -343,10 +425,9 @@ def orders(
ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None, ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None,
) -> None: ) -> None:
"""Export active orders.""" """Export active orders."""
client = _sdk_client(ctx) _export_command(
_execute_export(
ctx, ctx,
lambda: client.orders(symbol=symbol, group=group, ticket=ticket), lambda client: client.orders(symbol=symbol, group=group, ticket=ticket),
) )
@@ -358,10 +439,9 @@ def positions(
ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None, ticket: Annotated[int | None, typer.Option(help="Ticket filter.")] = None,
) -> None: ) -> None:
"""Export open positions.""" """Export open positions."""
client = _sdk_client(ctx) _export_command(
_execute_export(
ctx, ctx,
lambda: client.positions(symbol=symbol, group=group, ticket=ticket), lambda client: client.positions(symbol=symbol, group=group, ticket=ticket),
) )
@@ -382,10 +462,9 @@ def history_orders(
position: Annotated[int | None, typer.Option(help="Position ticket.")] = None, position: Annotated[int | None, typer.Option(help="Position ticket.")] = None,
) -> None: ) -> None:
"""Export historical orders.""" """Export historical orders."""
client = _sdk_client(ctx) _export_command(
_execute_export(
ctx, ctx,
lambda: client.history_orders( lambda client: client.history_orders(
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
group=group, group=group,
@@ -413,10 +492,9 @@ def history_deals(
position: Annotated[int | None, typer.Option(help="Position ticket.")] = None, position: Annotated[int | None, typer.Option(help="Position ticket.")] = None,
) -> None: ) -> None:
"""Export historical deals.""" """Export historical deals."""
client = _sdk_client(ctx) _export_command(
_execute_export(
ctx, ctx,
lambda: client.history_deals( lambda client: client.history_deals(
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
group=group, group=group,
@@ -427,16 +505,45 @@ def history_deals(
) )
@app.command()
def recent_history_deals(
ctx: typer.Context,
hours: Annotated[float, typer.Option(help="Lookback window in hours.")],
date_to: Annotated[
datetime | None,
typer.Option(click_type=DATETIME_TYPE, help="Window end date."),
] = None,
group: Annotated[str | None, typer.Option(help="Group filter.")] = None,
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
) -> None:
"""Export historical deals from a recent trailing window."""
_export_command(
ctx,
lambda client: client.recent_history_deals(
hours,
date_to=date_to,
group=group,
symbol=symbol,
),
)
@app.command()
def mt5_summary(ctx: typer.Context) -> None:
"""Export a compact terminal/account status summary."""
_export_command(ctx, lambda client: client.mt5_summary_as_df())
@app.command() @app.command()
def version(ctx: typer.Context) -> None: def version(ctx: typer.Context) -> None:
"""Export MetaTrader5 version information.""" """Export MetaTrader5 version information."""
_execute_export(ctx, _sdk_client(ctx).version) _export_command(ctx, lambda client: client.version())
@app.command() @app.command()
def last_error(ctx: typer.Context) -> None: def last_error(ctx: typer.Context) -> None:
"""Export the last error information.""" """Export the last error information."""
_execute_export(ctx, _sdk_client(ctx).last_error) _export_command(ctx, lambda client: client.last_error())
@app.command() @app.command()
@@ -445,8 +552,7 @@ def symbol_info_tick(
symbol: Annotated[str, typer.Option(help="Symbol name.")], symbol: Annotated[str, typer.Option(help="Symbol name.")],
) -> None: ) -> None:
"""Export the last tick for a symbol.""" """Export the last tick for a symbol."""
client = _sdk_client(ctx) _export_command(ctx, lambda client: client.symbol_info_tick(symbol))
_execute_export(ctx, lambda: client.symbol_info_tick(symbol))
@app.command() @app.command()
@@ -455,8 +561,7 @@ def market_book(
symbol: Annotated[str, typer.Option(help="Symbol name.")], symbol: Annotated[str, typer.Option(help="Symbol name.")],
) -> None: ) -> None:
"""Export market depth (order book) for a symbol.""" """Export market depth (order book) for a symbol."""
client = _sdk_client(ctx) _export_command(ctx, lambda client: client.market_book(symbol))
_execute_export(ctx, lambda: client.market_book(symbol))
@app.command() @app.command()
@@ -552,7 +657,7 @@ def collect_history(
click_type=TICK_FLAGS_TYPE, click_type=TICK_FLAGS_TYPE,
help="Tick copy flags (ALL, INFO, TRADE, or integer).", help="Tick copy flags (ALL, INFO, TRADE, or integer).",
), ),
] = 1, ] = "ALL", # pyright: ignore[reportArgumentType]
if_exists: Annotated[ if_exists: Annotated[
IfExists, IfExists,
typer.Option( typer.Option(
+792 -67
View File
File diff suppressed because it is too large Load Diff
+1063 -13
View File
File diff suppressed because it is too large Load Diff
+210
View File
@@ -0,0 +1,210 @@
"""Trading-capable MetaTrader 5 session helpers and operational utilities."""
from __future__ import annotations
from contextlib import contextmanager
from typing import TYPE_CHECKING, Literal
from pdmt5 import Mt5Config, Mt5TradingClient
from .sdk import build_config
if TYPE_CHECKING:
from collections.abc import Iterator
import pandas as pd
PositionSide = Literal["long", "short"]
OrderSide = Literal["long", "short"]
__all__ = [
"OrderSide",
"PositionSide",
"calculate_margin_and_volume",
"detect_position_side",
"determine_order_limits",
"mt5_trading_session",
]
def _require_unit_ratio(value: float, name: str) -> None:
if not 0.0 <= value <= 1.0:
msg = f"{name} must be between 0 and 1 inclusive."
raise ValueError(msg)
def _require_protective_ratio(value: float, name: str) -> None:
if not 0.0 <= value < 1.0:
msg = f"{name} must be at least 0 and less than 1."
raise ValueError(msg)
def _sum_position_volume(positions: pd.DataFrame, position_type: object) -> float:
matched = positions.loc[positions["type"] == position_type, "volume"]
if matched.empty:
return 0.0
return float(matched.to_numpy(dtype=float).sum())
def _normalize_order_side(side: str) -> OrderSide:
normalized = side.lower()
if normalized in {"long", "buy"}:
return "long"
if normalized in {"short", "sell"}:
return "short"
msg = (
f"Unsupported order side: {side!r}. Expected 'long', 'short', 'buy', or 'sell'."
)
raise ValueError(msg)
def detect_position_side(
client: Mt5TradingClient,
symbol: str,
) -> PositionSide | None:
"""Detect the net open position side for a symbol.
Args:
client: Connected ``Mt5TradingClient`` instance.
symbol: Symbol to inspect.
Returns:
``"long"`` when net buy volume exceeds sell volume, ``"short"`` when
net sell volume exceeds buy volume, or ``None`` when no positions exist
or buy/sell volumes are exactly balanced.
"""
positions = client.positions_get_as_df(symbol=symbol)
if positions.empty:
return None
buy_type = client.mt5.POSITION_TYPE_BUY
sell_type = client.mt5.POSITION_TYPE_SELL
buy_volume = _sum_position_volume(positions, buy_type)
sell_volume = _sum_position_volume(positions, sell_type)
net_volume = buy_volume - sell_volume
if net_volume > 0:
return "long"
if net_volume < 0:
return "short"
return None
def calculate_margin_and_volume(
client: Mt5TradingClient,
symbol: str,
unit_margin_ratio: float,
preserved_margin_ratio: float,
) -> dict[str, float]:
"""Calculate tradable margin and volumes from account free margin.
Applies ``preserved_margin_ratio`` to keep a reserve off ``margin_free``,
then allocates ``unit_margin_ratio`` of the remainder as the margin budget
for volume sizing on both buy and sell sides.
Args:
client: Connected ``Mt5TradingClient`` instance.
symbol: Symbol used for minimum-lot margin and volume calculations.
unit_margin_ratio: Fraction of post-reserve margin to allocate per unit.
preserved_margin_ratio: Fraction of ``margin_free`` to preserve.
Returns:
Dictionary with ``margin_free``, ``available_margin``, ``trade_margin``,
``buy_volume``, and ``sell_volume``. Negative ``margin_free`` values are
clamped to ``0.0`` before sizing.
"""
_require_unit_ratio(unit_margin_ratio, "unit_margin_ratio")
_require_unit_ratio(preserved_margin_ratio, "preserved_margin_ratio")
account = client.account_info_as_dict()
margin_free = max(0.0, float(account.get("margin_free") or 0.0))
available_margin = margin_free * (1.0 - preserved_margin_ratio)
trade_margin = available_margin * unit_margin_ratio
buy_volume = client.calculate_volume_by_margin(symbol, trade_margin, "BUY")
sell_volume = client.calculate_volume_by_margin(symbol, trade_margin, "SELL")
return {
"margin_free": margin_free,
"available_margin": available_margin,
"trade_margin": trade_margin,
"buy_volume": buy_volume,
"sell_volume": sell_volume,
}
def determine_order_limits(
client: Mt5TradingClient,
symbol: str,
side: OrderSide | str,
stop_loss_limit_ratio: float,
take_profit_limit_ratio: float,
) -> dict[str, float | None]:
"""Derive entry and protective order prices from current market quotes.
Args:
client: Connected ``Mt5TradingClient`` instance.
symbol: Symbol used for the quote lookup.
side: Position side as ``"long"``/``"short"`` (``"buy"``/``"sell"``
aliases are accepted).
stop_loss_limit_ratio: Relative distance from entry for stop loss in
``[0, 1)``. A value of ``0`` omits the stop loss.
take_profit_limit_ratio: Relative distance from entry for take profit in
``[0, 1)``. A value of ``0`` omits the take profit.
Returns:
Dictionary with ``entry``, ``stop_loss``, and ``take_profit`` keys.
Omitted protective levels are returned as ``None``.
"""
_require_protective_ratio(stop_loss_limit_ratio, "stop_loss_limit_ratio")
_require_protective_ratio(take_profit_limit_ratio, "take_profit_limit_ratio")
normalized_side = _normalize_order_side(side)
tick = client.symbol_info_tick_as_dict(symbol=symbol)
entry = float(tick["ask"] if normalized_side == "long" else tick["bid"])
stop_loss: float | None = None
if stop_loss_limit_ratio > 0:
if normalized_side == "long":
stop_loss = entry * (1.0 - stop_loss_limit_ratio)
else:
stop_loss = entry * (1.0 + stop_loss_limit_ratio)
take_profit: float | None = None
if take_profit_limit_ratio > 0:
if normalized_side == "long":
take_profit = entry * (1.0 + take_profit_limit_ratio)
else:
take_profit = entry * (1.0 - take_profit_limit_ratio)
return {
"entry": entry,
"stop_loss": stop_loss,
"take_profit": take_profit,
}
@contextmanager
def mt5_trading_session(
config: Mt5Config | None = None,
retry_count: int = 0,
) -> Iterator[Mt5TradingClient]:
"""Open a trading-capable MT5 session and always shut down safely.
Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set,
initializes and logs in via ``initialize_and_login_mt5()``, yields a
connected :class:`~pdmt5.Mt5TradingClient`, and calls ``shutdown()`` on
exit even when an error is raised inside the context.
Args:
config: MT5 connection configuration. Defaults to an empty config that
attaches to a running terminal.
retry_count: Number of initialization retries passed to
``Mt5TradingClient``.
Yields:
Connected ``Mt5TradingClient`` bound to the session.
"""
mt5_config = config or build_config()
client = Mt5TradingClient(config=mt5_config, retry_count=retry_count)
try:
client.initialize_and_login_mt5()
yield client
finally:
client.shutdown()
+86 -60
View File
@@ -2,51 +2,36 @@
from __future__ import annotations from __future__ import annotations
import importlib
import json import json
import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeGuard, cast from typing import TYPE_CHECKING, Any, TypeGuard
import click import click
from pdmt5 import COPY_TICKS_MAP, TIMEFRAME_MAP
from pdmt5 import parse_copy_ticks as _parse_copy_ticks
from pdmt5 import parse_timeframe as _parse_timeframe
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence
import pandas as pd import pandas as pd
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
TIMEFRAME_MAP: dict[str, int] = { # Backward-compatible snapshot; prefer ``COPY_TICKS_MAP`` from pdmt5 directly.
"M1": 1, TICK_FLAG_MAP: dict[str, int] = dict(COPY_TICKS_MAP)
"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] = { TIMEFRAME_NAMES: tuple[str, ...] = tuple(
"ALL": 1, name for name in TIMEFRAME_MAP if not name.startswith("TIMEFRAME_")
"INFO": 2, )
"TRADE": 4, _TICK_FLAG_NAMES: tuple[str, ...] = tuple(
} name for name in COPY_TICKS_MAP if not name.startswith("COPY_TICKS_")
)
_FORMAT_EXTENSIONS: dict[str, str] = { _FORMAT_EXTENSIONS: dict[str, str] = {
".csv": "csv", ".csv": "csv",
@@ -158,10 +143,8 @@ class _TimeframeType(click.ParamType):
Returns: Returns:
Integer timeframe value. Integer timeframe value.
""" """
if isinstance(value, int):
return value
try: try:
return parse_timeframe(str(value)) return parse_timeframe(value)
except ValueError as exc: except ValueError as exc:
self.fail(str(exc), param, ctx) self.fail(str(exc), param, ctx)
@@ -187,10 +170,8 @@ class _TickFlagsType(click.ParamType):
Returns: Returns:
Integer tick flag value. Integer tick flag value.
""" """
if isinstance(value, int):
return value
try: try:
return parse_tick_flags(str(value)) return parse_tick_flags(value)
except ValueError as exc: except ValueError as exc:
self.fail(str(exc), param, ctx) self.fail(str(exc), param, ctx)
@@ -260,6 +241,50 @@ def detect_format(
raise ValueError(msg) raise ValueError(msg)
def export_dataframe_to_sqlite(
df: pd.DataFrame,
output_path: Path,
table_name: str = "data",
*,
if_exists: IfExists = IfExists.APPEND,
index: bool = False,
index_label: str | None = None,
deduplicate_on: Sequence[str] | None = None,
) -> None:
"""Write a DataFrame to SQLite with configurable append and deduplication.
Args:
df: DataFrame to export.
output_path: SQLite database path.
table_name: Target table name.
if_exists: Conflict behavior when the table already exists.
index: Whether to write the DataFrame index as a column.
index_label: Column name for the index when ``index=True``.
deduplicate_on: Optional key columns to deduplicate after writing,
keeping the latest ``ROWID`` per key group. Deduplication scans the
full table, so repeated appends cost O(table size); index the key
columns when appending frequently.
"""
with sqlite3.connect(output_path) as conn:
df.to_sql( # type: ignore[reportUnknownMemberType]
table_name,
conn,
if_exists=if_exists.value,
index=index,
index_label=index_label,
)
if deduplicate_on:
from .history import drop_duplicates_in_table # noqa: PLC0415
drop_duplicates_in_table(
conn.cursor(),
table_name,
list(deduplicate_on),
keep="last",
)
conn.commit()
def export_dataframe( def export_dataframe(
df: pd.DataFrame, df: pd.DataFrame,
output_path: Path, output_path: Path,
@@ -289,14 +314,13 @@ def export_dataframe(
elif output_format == "parquet": elif output_format == "parquet":
df.to_parquet(output_path, index=False) df.to_parquet(output_path, index=False)
elif output_format == "sqlite3": elif output_format == "sqlite3":
sqlite3 = cast("Any", importlib.import_module("sqlite3")) export_dataframe_to_sqlite(
with sqlite3.connect(output_path) as conn: df,
df.to_sql( # type: ignore[reportUnknownMemberType] output_path,
table_name, table_name,
conn, if_exists=IfExists.REPLACE,
if_exists="replace", index=False,
index=False, )
)
else: else:
msg = f"Unsupported output format: {output_format}" msg = f"Unsupported output format: {output_format}"
raise ValueError(msg) raise ValueError(msg)
@@ -325,7 +349,7 @@ def parse_datetime(value: str) -> datetime:
return dt return dt
def parse_timeframe(value: str) -> int: def parse_timeframe(value: object) -> int:
"""Parse a timeframe string or integer value. """Parse a timeframe string or integer value.
Args: Args:
@@ -337,37 +361,39 @@ def parse_timeframe(value: str) -> int:
Raises: Raises:
ValueError: If the timeframe is invalid. ValueError: If the timeframe is invalid.
""" """
upper = value.upper()
if upper in TIMEFRAME_MAP:
return TIMEFRAME_MAP[upper]
try: try:
return int(value) return _parse_timeframe(value)
except ValueError: except ValueError:
valid = ", ".join(TIMEFRAME_MAP) display = value if isinstance(value, str) else repr(value)
msg = f"Invalid timeframe: '{value}'. Use one of: {valid}, or an integer." valid = ", ".join(TIMEFRAME_NAMES)
msg = (
f"Invalid timeframe: '{display}'. "
f"Use one of: {valid}, or a supported integer."
)
raise ValueError(msg) from None raise ValueError(msg) from None
def parse_tick_flags(value: str) -> int: def parse_tick_flags(value: object) -> int:
"""Parse tick flags string or integer value. """Parse tick flags string or integer value.
Args: Args:
value: Tick flag name (ALL, INFO, TRADE) or integer value. value: Tick flag name (ALL, INFO, TRADE, COPY_TICKS_*) or integer value.
Returns: Returns:
Integer tick flag value. Integer tick flag value compatible with MetaTrader 5 ``COPY_TICKS_*``.
Raises: Raises:
ValueError: If the flag is invalid. ValueError: If the flag is invalid.
""" """
upper = value.upper()
if upper in TICK_FLAG_MAP:
return TICK_FLAG_MAP[upper]
try: try:
return int(value) return _parse_copy_ticks(value)
except ValueError: except ValueError:
valid = ", ".join(TICK_FLAG_MAP) display = value if isinstance(value, str) else repr(value)
msg = f"Invalid tick flags: '{value}'. Use one of: {valid}, or an integer." valid = ", ".join(_TICK_FLAG_NAMES)
msg = (
f"Invalid tick flags: '{display}'. "
f"Use one of: {valid}, or a supported integer."
)
raise ValueError(msg) from None raise ValueError(msg) from None
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mt5cli" name = "mt5cli"
version = "0.4.2" version = "0.7.1"
description = "Command-line tool for MetaTrader 5" description = "Command-line tool for MetaTrader 5"
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
@@ -9,7 +9,7 @@ license-files = ["LICENSE"]
readme = "README.md" readme = "README.md"
requires-python = ">= 3.11, < 3.14" requires-python = ">= 3.11, < 3.14"
dependencies = [ dependencies = [
"pdmt5 >= 0.2.3", "pdmt5>=0.3.0",
"click >= 8.1.0", "click >= 8.1.0",
"pyarrow >= 19.0.0", "pyarrow >= 19.0.0",
"typer >= 0.15.0", "typer >= 0.15.0",
+52
View File
@@ -0,0 +1,52 @@
"""Shared pytest fixtures for mt5cli tests."""
from __future__ import annotations
from unittest.mock import MagicMock
import pandas as pd
import pytest
from pytest_mock import MockerFixture # noqa: TC002
_DATAFRAME_METHODS = (
"copy_rates_from_as_df",
"copy_rates_from_pos_as_df",
"copy_rates_range_as_df",
"copy_ticks_from_as_df",
"copy_ticks_range_as_df",
"account_info_as_df",
"terminal_info_as_df",
"symbols_get_as_df",
"symbol_info_as_df",
"orders_get_as_df",
"positions_get_as_df",
"history_orders_get_as_df",
"history_deals_get_as_df",
"version_as_df",
"last_error_as_df",
"symbol_info_tick_as_df",
"market_book_get_as_df",
"order_check_as_df",
"order_send_as_df",
)
def build_mock_mt5_data_client() -> MagicMock:
"""Return a MagicMock Mt5DataClient with common DataFrame stubs."""
client = MagicMock()
sample_df = pd.DataFrame({"col": [1]})
for method_name in _DATAFRAME_METHODS:
getattr(client, method_name).return_value = sample_df
client.version.return_value = (5, 0, 1)
client.terminal_info.return_value = {"connected": True, "paths": ["terminal.exe"]}
client.account_info.return_value = {"login": 123, "limits": {"modes": ["demo"]}}
client.symbols_total.return_value = 42
return client
@pytest.fixture
def mock_client(mocker: MockerFixture) -> MagicMock:
"""Create and patch a mock Mt5DataClient for CLI and SDK tests."""
client = build_mock_mt5_data_client()
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
return client
+173 -33
View File
@@ -6,7 +6,7 @@ import json
import logging import logging
import re import re
import sqlite3 import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock from unittest.mock import MagicMock
@@ -69,34 +69,6 @@ class TestExecuteExport:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.fixture
def mock_client(mocker: MockerFixture) -> MagicMock:
"""Create and patch a mock Mt5DataClient for CLI 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
client.order_check_as_df.return_value = sample_df
client.order_send_as_df.return_value = sample_df
mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client)
return client
class TestCommands: class TestCommands:
"""Tests for all CLI subcommands via CliRunner.""" """Tests for all CLI subcommands via CliRunner."""
@@ -223,6 +195,37 @@ class TestCommands:
count=50, count=50,
) )
def test_latest_rates(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test latest-rates command."""
output = tmp_path / "out.csv"
result = runner.invoke(
app,
[
"-o",
str(output),
"latest-rates",
"--symbol",
"GBPUSD",
"--timeframe",
"H1",
"--count",
"50",
"--start-pos",
"2",
],
)
assert result.exit_code == 0, result.output
mock_client.copy_rates_from_pos_as_df.assert_called_once_with(
symbol="GBPUSD",
timeframe=16385,
start_pos=2,
count=50,
)
def test_rates_range( def test_rates_range(
self, self,
tmp_path: Path, tmp_path: Path,
@@ -282,7 +285,7 @@ class TestCommands:
symbol="EURUSD", symbol="EURUSD",
date_from=datetime(2024, 1, 1, tzinfo=UTC), date_from=datetime(2024, 1, 1, tzinfo=UTC),
count=100, count=100,
flags=1, flags=-1,
) )
def test_ticks_range( def test_ticks_range(
@@ -313,9 +316,68 @@ class TestCommands:
symbol="EURUSD", symbol="EURUSD",
date_from=datetime(2024, 1, 1, tzinfo=UTC), date_from=datetime(2024, 1, 1, tzinfo=UTC),
date_to=datetime(2024, 2, 1, tzinfo=UTC), date_to=datetime(2024, 2, 1, tzinfo=UTC),
flags=2, flags=1,
) )
def test_ticks_recent(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test ticks-recent command."""
output = tmp_path / "out.csv"
result = runner.invoke(
app,
[
"-o",
str(output),
"ticks-recent",
"--symbol",
"EURUSD",
"--seconds",
"120",
"--date-to",
"2024-01-02",
"--count",
"500",
"--flags",
"ALL",
],
)
assert result.exit_code == 0, result.output
mock_client.copy_ticks_from_as_df.assert_called_once_with(
symbol="EURUSD",
date_from=datetime(2024, 1, 2, tzinfo=UTC) - timedelta(seconds=120),
count=500,
flags=-1,
)
mock_client.copy_ticks_range_as_df.assert_not_called()
def test_minimum_margins(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test minimum-margins command."""
sym = MagicMock(volume_min=0.01)
account = MagicMock(currency="USD")
tick = MagicMock(ask=1.1010, bid=1.1000)
mock_client.symbol_info.return_value = sym
mock_client.account_info.return_value = account
mock_client.symbol_info_tick.return_value = tick
mock_client.order_calc_margin.side_effect = [12.5, 12.4]
mock_client.mt5.ORDER_TYPE_BUY = 0
mock_client.mt5.ORDER_TYPE_SELL = 1
output = tmp_path / "out.csv"
result = runner.invoke(
app,
["-o", str(output), "minimum-margins", "--symbol", "EURUSD"],
)
assert result.exit_code == 0, result.output
mock_client.symbol_info.assert_called_once_with("EURUSD")
mock_client.order_calc_margin.assert_any_call(0, "EURUSD", 0.01, 1.1010)
mock_client.order_calc_margin.assert_any_call(1, "EURUSD", 0.01, 1.1000)
def test_orders( def test_orders(
self, self,
tmp_path: Path, tmp_path: Path,
@@ -392,6 +454,84 @@ class TestCommands:
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
mock_client.history_deals_get_as_df.assert_called_once() mock_client.history_deals_get_as_df.assert_called_once()
def test_recent_history_deals(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test recent-history-deals command."""
output = tmp_path / "out.csv"
result = runner.invoke(
app,
[
"-o",
str(output),
"recent-history-deals",
"--hours",
"6",
"--date-to",
"2024-01-02",
"--symbol",
"EURUSD",
],
)
assert result.exit_code == 0, result.output
mock_client.history_deals_get_as_df.assert_called_once_with(
date_from=datetime(2024, 1, 1, 18, tzinfo=UTC),
date_to=datetime(2024, 1, 2, tzinfo=UTC),
group=None,
symbol="EURUSD",
ticket=None,
position=None,
)
@pytest.mark.parametrize(
("filename", "reader"),
[
("summary.csv", "csv"),
("summary.json", "json"),
("summary.db", "sqlite3"),
("summary.parquet", "parquet"),
],
)
def test_mt5_summary_export_formats(
self,
tmp_path: Path,
mock_client: MagicMock,
filename: str,
reader: str,
) -> None:
"""Test mt5-summary writes export-safe files for supported formats."""
output = tmp_path / filename
result = runner.invoke(app, ["-o", str(output), "mt5-summary"])
assert result.exit_code == 0, result.output
assert output.exists()
mock_client.version.assert_called_once()
mock_client.terminal_info.assert_called_once()
mock_client.account_info.assert_called_once()
mock_client.symbols_total.assert_called_once()
if reader == "csv":
frame = pd.read_csv(output)
elif reader == "json":
with output.open() as f:
records = json.load(f)
frame = pd.DataFrame(records)
elif reader == "sqlite3":
with sqlite3.connect(output) as conn:
frame = pd.read_sql( # type: ignore[reportUnknownMemberType]
"SELECT * FROM data",
conn,
)
else:
frame = pd.read_parquet(output)
assert len(frame) == 1
assert frame.iloc[0].to_dict() == {
"version": "[5,0,1]",
"terminal_info": '{"connected":true,"paths":["terminal.exe"]}',
"account_info": '{"limits":{"modes":["demo"]},"login":123}',
"symbols_total": 42,
}
def test_version( def test_version(
self, self,
tmp_path: Path, tmp_path: Path,
@@ -828,7 +968,7 @@ class TestCollectHistory:
symbol="EURUSD", symbol="EURUSD",
date_from=datetime(2024, 1, 1, tzinfo=UTC), date_from=datetime(2024, 1, 1, tzinfo=UTC),
date_to=datetime(2024, 2, 1, tzinfo=UTC), date_to=datetime(2024, 2, 1, tzinfo=UTC),
flags=1, flags=-1,
) )
with sqlite3.connect(output) as conn: with sqlite3.connect(output) as conn:
tables = { tables = {
@@ -1041,7 +1181,7 @@ class TestCollectHistory:
symbol="EURUSD", symbol="EURUSD",
date_from=datetime(2024, 1, 1, tzinfo=UTC), date_from=datetime(2024, 1, 1, tzinfo=UTC),
date_to=datetime(2024, 2, 1, tzinfo=UTC), date_to=datetime(2024, 2, 1, tzinfo=UTC),
flags=1, flags=-1,
) )
def test_collect_history_with_views( def test_collect_history_with_views(
+988 -4
View File
File diff suppressed because it is too large Load Diff
+1268 -33
View File
File diff suppressed because it is too large Load Diff
+356
View File
@@ -0,0 +1,356 @@
"""Tests for trading session helpers and operational utilities."""
from __future__ import annotations
from unittest.mock import MagicMock
import pandas as pd
import pytest
from pdmt5 import Mt5RuntimeError
from pytest_mock import MockerFixture # noqa: TC002
from mt5cli.sdk import build_config
from mt5cli.trading import (
calculate_margin_and_volume,
detect_position_side,
determine_order_limits,
mt5_trading_session,
)
class TestDetectPositionSide:
"""Tests for detect_position_side."""
def test_returns_none_when_no_positions(self) -> None:
"""Test None is returned when no open positions exist."""
client = MagicMock()
client.positions_get_as_df.return_value = pd.DataFrame()
assert detect_position_side(client, "EURUSD") is None
def test_returns_long_for_net_buy_volume(self) -> None:
"""Test long is returned when buy volume exceeds sell volume."""
client = MagicMock()
client.mt5.POSITION_TYPE_BUY = 0
client.mt5.POSITION_TYPE_SELL = 1
client.positions_get_as_df.return_value = pd.DataFrame(
{
"type": [0, 0, 1],
"volume": [0.2, 0.1, 0.05],
},
)
assert detect_position_side(client, "EURUSD") == "long"
def test_returns_short_for_net_sell_volume(self) -> None:
"""Test short is returned when sell volume exceeds buy volume."""
client = MagicMock()
client.mt5.POSITION_TYPE_BUY = 0
client.mt5.POSITION_TYPE_SELL = 1
client.positions_get_as_df.return_value = pd.DataFrame(
{
"type": [1, 1],
"volume": [0.3, 0.1],
},
)
assert detect_position_side(client, "EURUSD") == "short"
def test_returns_none_for_balanced_hedged_positions(self) -> None:
"""Test None is returned when buy and sell volumes net to zero."""
client = MagicMock()
client.mt5.POSITION_TYPE_BUY = 0
client.mt5.POSITION_TYPE_SELL = 1
client.positions_get_as_df.return_value = pd.DataFrame(
{
"type": [0, 1],
"volume": [0.2, 0.2],
},
)
assert detect_position_side(client, "EURUSD") is None
class TestCalculateMarginAndVolume:
"""Tests for calculate_margin_and_volume."""
def test_calculates_margin_budget_and_volumes(self) -> None:
"""Test margin budget and buy/sell volumes are derived from ratios."""
client = MagicMock()
client.account_info_as_dict.return_value = {"margin_free": 1000.0}
client.calculate_volume_by_margin.side_effect = [0.3, 0.2]
result = calculate_margin_and_volume(
client,
"EURUSD",
unit_margin_ratio=0.5,
preserved_margin_ratio=0.2,
)
assert result == {
"margin_free": 1000.0,
"available_margin": 800.0,
"trade_margin": 400.0,
"buy_volume": 0.3,
"sell_volume": 0.2,
}
client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "BUY")
client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "SELL")
@pytest.mark.parametrize(
("account_dict", "expected_margin_free"),
[
({"margin_free": 0.0}, 0.0),
({}, 0.0),
({"margin_free": None}, 0.0),
],
)
def test_zero_or_missing_margin_free(
self,
account_dict: dict[str, float | None],
expected_margin_free: float,
) -> None:
"""Test missing or zero margin_free yields zero trade margin."""
client = MagicMock()
client.account_info_as_dict.return_value = account_dict
client.calculate_volume_by_margin.return_value = 0.0
result = calculate_margin_and_volume(
client,
"EURUSD",
unit_margin_ratio=0.5,
preserved_margin_ratio=0.2,
)
assert result["margin_free"] == expected_margin_free
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "BUY")
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "SELL")
def test_clamps_negative_margin_free_to_zero(self) -> None:
"""Test negative margin_free is clamped to zero before sizing."""
client = MagicMock()
client.account_info_as_dict.return_value = {"margin_free": -500.0}
client.calculate_volume_by_margin.return_value = 0.0
result = calculate_margin_and_volume(
client,
"EURUSD",
unit_margin_ratio=0.5,
preserved_margin_ratio=0.2,
)
expected_margin_free = 0.0
assert result["margin_free"] == expected_margin_free
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "BUY")
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "SELL")
@pytest.mark.parametrize(
("unit_ratio", "preserved_ratio"),
[
(-0.1, 0.0),
(1.1, 0.0),
(0.5, -0.1),
(0.5, 1.1),
],
)
def test_rejects_invalid_ratios(
self,
unit_ratio: float,
preserved_ratio: float,
) -> None:
"""Test invalid ratio values raise ValueError."""
with pytest.raises(ValueError, match="must be between 0 and 1"):
calculate_margin_and_volume(
MagicMock(),
"EURUSD",
unit_margin_ratio=unit_ratio,
preserved_margin_ratio=preserved_ratio,
)
class TestDetermineOrderLimits:
"""Tests for determine_order_limits."""
@pytest.mark.parametrize(
("side", "expected_entry_key"),
[
("long", "ask"),
("short", "bid"),
("buy", "ask"),
("sell", "bid"),
],
)
def test_uses_expected_quote_for_entry(
self,
side: str,
expected_entry_key: str,
) -> None:
"""Test entry price is taken from ask for long/buy and bid for short/sell."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
result = determine_order_limits(
client,
"EURUSD",
side,
stop_loss_limit_ratio=0.0,
take_profit_limit_ratio=0.0,
)
assert (
result["entry"]
== client.symbol_info_tick_as_dict.return_value[expected_entry_key]
)
assert result["stop_loss"] is None
assert result["take_profit"] is None
def test_calculates_long_protective_levels(self) -> None:
"""Test long stop loss and take profit are placed below/above entry."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
result = determine_order_limits(
client,
"EURUSD",
"long",
stop_loss_limit_ratio=0.02,
take_profit_limit_ratio=0.03,
)
assert result == {
"entry": 100.0,
"stop_loss": 98.0,
"take_profit": 103.0,
}
def test_calculates_short_protective_levels(self) -> None:
"""Test short stop loss and take profit are placed above/below entry."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
result = determine_order_limits(
client,
"EURUSD",
"short",
stop_loss_limit_ratio=0.02,
take_profit_limit_ratio=0.03,
)
assert result == {
"entry": 99.0,
"stop_loss": 100.98,
"take_profit": 96.03,
}
def test_rejects_unknown_side(self) -> None:
"""Test unsupported side values raise ValueError."""
with pytest.raises(ValueError, match="Unsupported order side"):
determine_order_limits(
MagicMock(),
"EURUSD",
"flat",
stop_loss_limit_ratio=0.01,
take_profit_limit_ratio=0.01,
)
@pytest.mark.parametrize(
("stop_loss_ratio", "take_profit_ratio"),
[
(-0.05, 0.01),
(0.01, 2.0),
],
)
def test_rejects_invalid_protective_ratios(
self,
stop_loss_ratio: float,
take_profit_ratio: float,
) -> None:
"""Test out-of-range protective ratios raise ValueError."""
with pytest.raises(ValueError, match="must be at least 0 and less than 1"):
determine_order_limits(
MagicMock(),
"EURUSD",
"long",
stop_loss_limit_ratio=stop_loss_ratio,
take_profit_limit_ratio=take_profit_ratio,
)
@pytest.mark.parametrize(
("field", "ratio"),
[
("stop_loss_limit_ratio", 1.0),
("take_profit_limit_ratio", 1.0),
],
)
def test_rejects_unit_boundary_protective_ratios(
self,
field: str,
ratio: float,
) -> None:
"""Test protective ratios of exactly 1.0 are rejected."""
kwargs = {
"stop_loss_limit_ratio": 0.01,
"take_profit_limit_ratio": 0.01,
field: ratio,
}
with pytest.raises(ValueError, match="must be at least 0 and less than 1"):
determine_order_limits(
MagicMock(),
"EURUSD",
"long",
**kwargs,
)
class TestMt5TradingSession:
"""Tests for the mt5_trading_session context manager."""
def test_yields_connected_client_and_shuts_down(
self,
mocker: MockerFixture,
) -> None:
"""Test mt5_trading_session connects, yields a client, and shuts down."""
mock_client = MagicMock()
trading_client = mocker.patch(
"mt5cli.trading.Mt5TradingClient",
return_value=mock_client,
)
with mt5_trading_session(
build_config(path="/opt/mt5/terminal64.exe"),
retry_count=2,
) as client:
mock_client.initialize_and_login_mt5.assert_called_once()
assert client is mock_client
trading_client.assert_called_once()
assert trading_client.call_args.kwargs["retry_count"] == 2
assert (
trading_client.call_args.kwargs["config"].path == "/opt/mt5/terminal64.exe"
)
mock_client.shutdown.assert_called_once()
def test_shuts_down_when_initialize_raises(
self,
mocker: MockerFixture,
) -> None:
"""Test shutdown is called when initialization fails."""
mock_client = MagicMock()
mock_client.initialize_and_login_mt5.side_effect = Mt5RuntimeError("boom")
mocker.patch("mt5cli.trading.Mt5TradingClient", return_value=mock_client)
with pytest.raises(Mt5RuntimeError, match="boom"), mt5_trading_session():
pass
mock_client.shutdown.assert_called_once()
def test_shuts_down_when_body_raises(self, mocker: MockerFixture) -> None:
"""Test shutdown is called when the context body raises."""
mock_client = MagicMock()
mocker.patch("mt5cli.trading.Mt5TradingClient", return_value=mock_client)
body_error = "body error"
with pytest.raises(RuntimeError, match=body_error), mt5_trading_session():
raise RuntimeError(body_error)
mock_client.shutdown.assert_called_once()
+159 -14
View File
@@ -21,8 +21,10 @@ from mt5cli.utils import (
TIMEFRAME_MAP, TIMEFRAME_MAP,
TIMEFRAME_TYPE, TIMEFRAME_TYPE,
Dataset, Dataset,
IfExists,
detect_format, detect_format,
export_dataframe, export_dataframe,
export_dataframe_to_sqlite,
parse_datetime, parse_datetime,
parse_request, parse_request,
parse_tick_flags, parse_tick_flags,
@@ -130,6 +132,112 @@ class TestExportDataframe:
export_dataframe(sample_df, tmp_path / "out.txt", "xml") export_dataframe(sample_df, tmp_path / "out.txt", "xml")
class TestExportDataframeToSqlite:
"""Tests for export_dataframe_to_sqlite."""
def test_append_preserves_existing_rows(self, tmp_path: Path) -> None:
"""Test append mode keeps prior rows in the SQLite table."""
output = tmp_path / "append.db"
first = pd.DataFrame({"id": [1], "value": ["a"]})
second = pd.DataFrame({"id": [2], "value": ["b"]})
export_dataframe_to_sqlite(first, output, "items", if_exists=IfExists.REPLACE)
export_dataframe_to_sqlite(second, output, "items", if_exists=IfExists.APPEND)
with sqlite3.connect(output) as conn:
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
"SELECT id, value FROM items ORDER BY id",
conn,
)
pd.testing.assert_frame_equal(
result,
pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}),
)
def test_deduplicate_keeps_latest_row(self, tmp_path: Path) -> None:
"""Test deduplication keeps the latest ROWID for key columns."""
output = tmp_path / "dedup.db"
first = pd.DataFrame({
"symbol": ["EURUSD", "EURUSD"],
"time": ["2024-01-01", "2024-01-01"],
"bid": [1.0, 1.1],
})
second = pd.DataFrame({
"symbol": ["EURUSD"],
"time": ["2024-01-01"],
"bid": [1.2],
})
export_dataframe_to_sqlite(
first,
output,
"ticks",
if_exists=IfExists.REPLACE,
deduplicate_on=("symbol", "time"),
)
export_dataframe_to_sqlite(
second,
output,
"ticks",
if_exists=IfExists.APPEND,
deduplicate_on=("symbol", "time"),
)
with sqlite3.connect(output) as conn:
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
"SELECT symbol, time, bid FROM ticks",
conn,
)
pd.testing.assert_frame_equal(
result.reset_index(drop=True),
pd.DataFrame({
"symbol": ["EURUSD"],
"time": ["2024-01-01"],
"bid": [1.2],
}),
)
def test_default_if_exists_appends_without_dropping_rows(
self,
tmp_path: Path,
) -> None:
"""Test the default append mode keeps prior rows."""
output = tmp_path / "default-append.db"
first = pd.DataFrame({"id": [1], "value": ["a"]})
second = pd.DataFrame({"id": [2], "value": ["b"]})
export_dataframe_to_sqlite(first, output, "items")
export_dataframe_to_sqlite(second, output, "items")
with sqlite3.connect(output) as conn:
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
"SELECT id, value FROM items ORDER BY id",
conn,
)
pd.testing.assert_frame_equal(
result,
pd.DataFrame({"id": [1, 2], "value": ["a", "b"]}),
)
def test_writes_index_with_label(self, tmp_path: Path) -> None:
"""Test optional index export with a custom label."""
output = tmp_path / "index.db"
frame = pd.DataFrame(
{"value": [1.0]}, index=pd.Index(["EURUSD"], name="symbol")
)
export_dataframe_to_sqlite(
frame,
output,
"margins",
if_exists=IfExists.REPLACE,
index=True,
index_label="symbol",
)
with sqlite3.connect(output) as conn:
result = pd.read_sql( # type: ignore[reportUnknownMemberType]
"SELECT symbol, value FROM margins",
conn,
)
pd.testing.assert_frame_equal(
result,
pd.DataFrame({"symbol": ["EURUSD"], "value": [1.0]}),
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Parse helpers # Parse helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -166,8 +274,14 @@ class TestParseTimeframe:
assert parse_timeframe(value) == expected assert parse_timeframe(value) == expected
def test_integer_timeframe(self) -> None: def test_integer_timeframe(self) -> None:
"""Test parsing integer timeframe.""" """Test parsing supported integer timeframes."""
assert parse_timeframe("42") == 42 assert parse_timeframe("1") == 1
assert parse_timeframe(16385) == 16385
def test_unsupported_integer_timeframe_raises(self) -> None:
"""Test that unsupported integer timeframes raise ValueError."""
with pytest.raises(ValueError, match="Invalid timeframe"):
parse_timeframe("42")
def test_invalid_timeframe_raises(self) -> None: def test_invalid_timeframe_raises(self) -> None:
"""Test that invalid timeframe raises ValueError.""" """Test that invalid timeframe raises ValueError."""
@@ -180,15 +294,21 @@ class TestParseTickFlags:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("value", "expected"), ("value", "expected"),
[("ALL", 1), ("info", 2), ("TRADE", 4)], [("ALL", -1), ("info", 1), ("TRADE", 2), ("COPY_TICKS_ALL", -1)],
) )
def test_named_flag(self, value: str, expected: int) -> None: def test_named_flag(self, value: str, expected: int) -> None:
"""Test parsing named tick flags.""" """Test parsing named tick flags."""
assert parse_tick_flags(value) == expected assert parse_tick_flags(value) == expected
def test_integer_flag(self) -> None: def test_integer_flag(self) -> None:
"""Test parsing integer tick flag.""" """Test parsing supported integer tick flags."""
assert parse_tick_flags("7") == 7 assert parse_tick_flags("-1") == -1
assert parse_tick_flags(2) == 2
def test_unsupported_integer_flag_raises(self) -> None:
"""Test that unsupported integer tick flags raise ValueError."""
with pytest.raises(ValueError, match="Invalid tick flags"):
parse_tick_flags("7")
def test_invalid_flag_raises(self) -> None: def test_invalid_flag_raises(self) -> None:
"""Test that invalid flag raises ValueError.""" """Test that invalid flag raises ValueError."""
@@ -247,8 +367,11 @@ class TestConstants:
assert key in TIMEFRAME_MAP assert key in TIMEFRAME_MAP
def test_tick_flag_map_has_expected_keys(self) -> None: def test_tick_flag_map_has_expected_keys(self) -> None:
"""Test that TICK_FLAG_MAP contains standard flags.""" """Test that TICK_FLAG_MAP contains standard flags with MT5 values."""
assert set(TICK_FLAG_MAP) == {"ALL", "INFO", "TRADE"} assert {"ALL", "INFO", "TRADE"} <= set(TICK_FLAG_MAP)
assert TICK_FLAG_MAP["ALL"] == -1
assert TICK_FLAG_MAP["INFO"] == 1
assert TICK_FLAG_MAP["TRADE"] == 2
@pytest.mark.parametrize( @pytest.mark.parametrize(
("dataset", "expected"), ("dataset", "expected"),
@@ -295,26 +418,48 @@ class TestTimeframeType:
"""Test converting a string to timeframe integer.""" """Test converting a string to timeframe integer."""
assert TIMEFRAME_TYPE.convert("H1", None, None) == 16385 assert TIMEFRAME_TYPE.convert("H1", None, None) == 16385
def test_convert_int_passthrough(self) -> None: def test_convert_int(self) -> None:
"""Test that integer values pass through unchanged.""" """Test converting supported integer timeframe values."""
assert TIMEFRAME_TYPE.convert(42, None, None) == 42 assert TIMEFRAME_TYPE.convert(16385, None, None) == 16385
def test_convert_unsupported_int(self) -> None:
"""Test that unsupported integer values raise BadParameter."""
with pytest.raises(Exception, match="Invalid timeframe"):
TIMEFRAME_TYPE.convert(42, None, None)
def test_convert_invalid(self) -> None: def test_convert_invalid(self) -> None:
"""Test that invalid values raise BadParameter.""" """Test that invalid values raise BadParameter."""
with pytest.raises(Exception, match="Invalid timeframe"): with pytest.raises(Exception, match="Invalid timeframe"):
TIMEFRAME_TYPE.convert("bad", None, None) TIMEFRAME_TYPE.convert("bad", None, None)
@pytest.mark.parametrize("value", [True, False, None, 1.5])
def test_convert_invalid_types(self, value: object) -> None:
"""Test that bool, float, and None values raise BadParameter."""
with pytest.raises(Exception, match="Invalid timeframe"):
TIMEFRAME_TYPE.convert(value, None, None)
class TestTickFlagsType: class TestTickFlagsType:
"""Tests for _TickFlagsType.""" """Tests for _TickFlagsType."""
def test_convert_string(self) -> None: def test_convert_string(self) -> None:
"""Test converting a string to tick flags integer.""" """Test converting a string to tick flags integer."""
assert TICK_FLAGS_TYPE.convert("ALL", None, None) == 1 assert TICK_FLAGS_TYPE.convert("ALL", None, None) == -1
def test_convert_int_passthrough(self) -> None: def test_convert_int(self) -> None:
"""Test that integer values pass through unchanged.""" """Test converting supported integer tick flag values."""
assert TICK_FLAGS_TYPE.convert(7, None, None) == 7 assert TICK_FLAGS_TYPE.convert(2, None, None) == 2
def test_convert_unsupported_int(self) -> None:
"""Test that unsupported integer values raise BadParameter."""
with pytest.raises(Exception, match="Invalid tick flags"):
TICK_FLAGS_TYPE.convert(7, None, None)
@pytest.mark.parametrize("value", [True, False, None, 1.5])
def test_convert_invalid_types(self, value: object) -> None:
"""Test that bool, float, and None values raise BadParameter."""
with pytest.raises(Exception, match="Invalid tick flags"):
TICK_FLAGS_TYPE.convert(value, None, None)
def test_convert_invalid(self) -> None: def test_convert_invalid(self) -> None:
"""Test that invalid values raise BadParameter.""" """Test that invalid values raise BadParameter."""
Generated
+8 -8
View File
@@ -487,7 +487,7 @@ wheels = [
[[package]] [[package]]
name = "mt5cli" name = "mt5cli"
version = "0.4.2" version = "0.7.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -513,7 +513,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "click", specifier = ">=8.1.0" }, { name = "click", specifier = ">=8.1.0" },
{ name = "pdmt5", specifier = ">=0.2.3" }, { name = "pdmt5", specifier = ">=0.3.0" },
{ name = "pyarrow", specifier = ">=19.0.0" }, { name = "pyarrow", specifier = ">=19.0.0" },
{ name = "typer", specifier = ">=0.15.0" }, { name = "typer", specifier = ">=0.15.0" },
] ]
@@ -684,16 +684,16 @@ wheels = [
[[package]] [[package]]
name = "pdmt5" name = "pdmt5"
version = "0.2.3" version = "0.3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "metatrader5", marker = "sys_platform == 'win32'" }, { name = "metatrader5", marker = "sys_platform == 'win32'" },
{ name = "pandas" }, { name = "pandas" },
{ name = "pydantic" }, { name = "pydantic" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/02/25/52d9d954504ccdd0fe91f715ab74c424d61234b237cc4160d3ebe20070f1/pdmt5-0.2.3.tar.gz", hash = "sha256:21384f5826fb0125fee3f93c90b108340f55ab53b1c819d229ceac162289d2ec", size = 226665, upload-time = "2026-02-05T13:28:21.071Z" } sdist = { url = "https://files.pythonhosted.org/packages/bf/cc/c8fa3a01e0e34178fec8527992f7bb8eda5881477ce23aaacaa9b2ef7bec/pdmt5-0.3.0.tar.gz", hash = "sha256:bb612d5c2695eafac9b2a7b74756e13bd383d7e5517bd90c9a2efa92492c484c", size = 215100, upload-time = "2026-06-11T13:26:46.976Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/75/c5e52a9cf459b85b2dd52f83e70857571b1b45805c9fe610b3959a26ac15/pdmt5-0.2.3-py3-none-any.whl", hash = "sha256:f92246a05cfc3b7feb3ab0cc5b48768a4d84aad6b02e7a68060948f5828718a1", size = 22967, upload-time = "2026-02-05T13:28:19.523Z" }, { url = "https://files.pythonhosted.org/packages/f2/03/b12cc4c9db983d971c9172b3765161b6d91136d0624e6718a04dd815e7a1/pdmt5-0.3.0-py3-none-any.whl", hash = "sha256:5388b406cc583202600cfe22c9d781679b1d931b1ed5a2b5dcf37c566149b49f", size = 26250, upload-time = "2026-06-11T13:26:45.689Z" },
] ]
[[package]] [[package]]
@@ -836,11 +836,11 @@ wheels = [
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.20.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
] ]
[[package]] [[package]]