Compare commits

...

4 Commits

Author SHA1 Message Date
Daichi Narushima 4bc36d09d2 fix: add copy_rates_from_pos_as_df fallback for trading client rate fetch (#88)
* fix: add copy_rates_from_pos_as_df fallback in fetch_latest_closed_rates_for_trading_client

Mt5DataClient (returned by create_trading_client) exposes copy_rates_from_pos_as_df,
not fetch_latest_rates_as_df. Adds a fallback path that resolves the granularity string
to an integer timeframe via parse_timeframe, fetches count+1 bars from start_pos=0,
and applies the same drop_forming_rate_bar + tail(count) logic so callers that use
the client returned by create_trading_client no longer need a compatibility shim.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Bump version to v1.0.3

* fix: hoist parse_timeframe before dispatch and add invalid-granularity test

Hoisting parse_timeframe(granularity) before the fetch_latest_rates_as_df /
copy_rates_from_pos_as_df dispatch ensures invalid granularity strings fail
consistently on both paths with a clear ValueError, rather than only when
the fallback branch is taken.

Adds test_copy_rates_from_pos_fallback_raises_on_invalid_granularity to pin
the early-validation contract and confirm the underlying method is never called.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 07:18:54 +09:00
dceoy 3a126ced30 BUmp version to 1.0.2 2026-06-28 06:08:21 +09:00
Daichi Narushima 80c3f3f65e Make ticks dataset opt-in for collect-history (#87)
* feat: make SQLite tick history opt-in for collect-history

Ticks can grow SQLite databases quickly, so they are excluded from the
default dataset selection. The new DEFAULT_HISTORY_DATASETS constant
(rates, history-orders, history-deals) drives resolve_history_datasets(None),
collect_history(), and update_history(). Callers must pass
--dataset ticks (CLI) or datasets={Dataset.ticks} (SDK) to include ticks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ALK71tg75JWrrCKaiShb7b

* chore: reformat docs/index.md table column widths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ALK71tg75JWrrCKaiShb7b

* fix: update stale docstrings and tighten CLI None check

- update_history and ThrottledHistoryUpdater.__init__ docstrings now
  state that ticks are opt-in, matching collect_history's wording
- cli.py collect-history uses `is not None` for explicit empty-list safety

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ALK71tg75JWrrCKaiShb7b

* docs: update README collect-history to reflect ticks opt-in default

The command table and section intro previously stated ticks were
collected by default ("all four", "rates, ticks, history-orders, and
history-deals"). Both now reflect the new default (rates, history-orders,
history-deals) and note that --dataset ticks is required to include ticks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ALK71tg75JWrrCKaiShb7b

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-28 06:00:26 +09:00
Daichi Narushima 43f632bc40 Reorganize CLI help text and command grouping for data/execution clarity (#85)
* feat: clarify CLI/docs scope as generic MT5 data and execution infrastructure

- Update app help text and module docstring to describe mt5cli as MT5 data
  and execution utilities rather than export-only tooling
- Group CLI commands under rich_help_panel sections: Data / Export, Execution,
  and Collection; command names are unchanged for compatibility
- Expand order-send docstring to explicitly flag it as the expert raw-request
  live-trading path; preserve --yes gate
- Split docs/index.md Trading section into "Trading State" (read-only) and
  "Execution (live / mutating)" with close-positions now documented
- Add TestHelpText tests verifying top-level panel grouping, order-send
  expert/live language, and close-positions safety gate coverage

Closes #78

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yH6esaqc5D1cmo1dK2Ur9

* chore: trim trailing whitespace in docs/index.md table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yH6esaqc5D1cmo1dK2Ur9

* chore: bump version to 1.0.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yH6esaqc5D1cmo1dK2Ur9

* chore: update uv.lock for version 1.0.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yH6esaqc5D1cmo1dK2Ur9

* fix: address review feedback on CLI/docs scope PR

- Remove dead help invocation in test_order_send_help_mentions_expert_and_raw
  (the result was immediately overwritten by result2)
- Strengthen assertion from `or` to `and`; both "raw" and "expert" are present
  in the docstring so disjunction masked a potential regression
- Split into two `assert` statements to satisfy PT018 (ruff)
- Fix docs/index.md inaccuracy: order-check has no --yes gate; clarify that
  only order-send and close-positions require confirmation for live execution
- Move order-check from "Execution" rich_help_panel to "Data / Export" so the
  Execution panel name is truthful (order-check is read-only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yH6esaqc5D1cmo1dK2Ur9

* docs: move order-check out of Execution section into Trading State

order-check is read-only and now lives in the CLI's Data / Export panel,
so documenting it under "Execution (live / mutating)" was inconsistent.
Moved it to the Trading State table. The Execution section now only lists
order-send and close-positions, both of which require --yes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yH6esaqc5D1cmo1dK2Ur9

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-28 01:23:17 +09:00
12 changed files with 418 additions and 115 deletions
+29 -29
View File
@@ -157,34 +157,34 @@ python -m mt5cli -o account.csv account-info
## Commands
| Command | Description |
| ---------------------- | ------------------------------------------------------------------------------------------------------------ |
| `rates-from` | Export rates from a start date |
| `rates-from-pos` | Export rates from a start position |
| `latest-rates` | Export latest rates from a start position |
| `rates-range` | Export rates for a date range |
| `ticks-from` | Export ticks from a start date |
| `ticks-range` | Export ticks for a date range |
| `ticks-recent` | Export ticks from a recent trailing window |
| `account-info` | Export account information |
| `terminal-info` | Export terminal information |
| `version` | Export MetaTrader 5 version information |
| `last-error` | Export the last error information |
| `symbols` | Export symbol list |
| `symbol-info` | Export symbol details |
| `symbol-info-tick` | Export the last tick for a symbol |
| `minimum-margins` | Export minimum-volume buy and sell margin requirements |
| `market-book` | Export market depth (order book) |
| `orders` | Export active orders |
| `positions` | Export open positions |
| `history-orders` | Export historical orders |
| `history-deals` | Export historical deals |
| `recent-history-deals` | Export historical deals from a recent trailing window |
| `mt5-summary` | Export terminal/account status summary |
| `order-check` | Check funds sufficiency for a trade request |
| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) |
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
| Command | Description |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `rates-from` | Export rates from a start date |
| `rates-from-pos` | Export rates from a start position |
| `latest-rates` | Export latest rates from a start position |
| `rates-range` | Export rates for a date range |
| `ticks-from` | Export ticks from a start date |
| `ticks-range` | Export ticks for a date range |
| `ticks-recent` | Export ticks from a recent trailing window |
| `account-info` | Export account information |
| `terminal-info` | Export terminal information |
| `version` | Export MetaTrader 5 version information |
| `last-error` | Export the last error information |
| `symbols` | Export symbol list |
| `symbol-info` | Export symbol details |
| `symbol-info-tick` | Export the last tick for a symbol |
| `minimum-margins` | Export minimum-volume buy and sell margin requirements |
| `market-book` | Export market depth (order book) |
| `orders` | Export active orders |
| `positions` | Export open positions |
| `history-orders` | Export historical orders |
| `history-deals` | Export historical deals |
| `recent-history-deals` | Export historical deals from a recent trailing window |
| `mt5-summary` | Export terminal/account status summary |
| `order-check` | Check funds sufficiency for a trade request |
| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) |
| `collect-history` | Collect rates, history-orders, and history-deals for one or more symbols into a single SQLite database (ticks opt-in via `--dataset ticks`) |
Use `order-check` to validate a request payload before running `order-send --yes`.
`close-positions` is the safer high-level alternative that builds correct close
@@ -192,7 +192,7 @@ requests automatically. At least one `--symbol` or `--ticket` must be provided.
### `collect-history`
Collect several historical datasets per symbol into one SQLite database in a single MT5 session. Pick datasets with repeatable `--dataset` (default: all four), choose conflict behavior with `--if-exists append|replace|fail` (default: `fail`), and optionally derive `cash_events` / `positions_reconstructed` views from `history_deals` via `--with-views`.
Collect several historical datasets per symbol into one SQLite database in a single MT5 session. Pick datasets with repeatable `--dataset` (default: `rates`, `history-orders`, `history-deals`; add `--dataset ticks` when tick-level history is required — tick data can grow the SQLite database quickly), choose conflict behavior with `--if-exists append|replace|fail` (default: `fail`), and optionally derive `cash_events` / `positions_reconstructed` views from `history_deals` via `--with-views`.
```bash
mt5cli -o history.db collect-history \
+38 -26
View File
@@ -147,26 +147,38 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
| `minimum-margins` | Export minimum-volume margin summary |
| `market-book` | Export market depth (order book) |
### Trading
### Trading State
| Command | Description |
| ---------------------- | ----------------------------------------------------------- |
| `orders` | Export active orders |
| `positions` | Export open positions |
| `history-orders` | Export historical orders |
| `history-deals` | Export historical deals |
| `recent-history-deals` | Export historical deals from a trailing window |
| `mt5-summary` | Export terminal/account status summary |
| `order-check` | Check funds sufficiency for a trade request |
| `order-send` | Send a trade request to the trade server (`--yes` required) |
| Command | Description |
| ---------------------- | ------------------------------------------------------------------- |
| `orders` | Export active orders |
| `positions` | Export open positions |
| `history-orders` | Export historical orders |
| `history-deals` | Export historical deals |
| `recent-history-deals` | Export historical deals from a trailing window |
| `mt5-summary` | Export terminal/account status summary |
| `order-check` | Check funds sufficiency for a trade request (read-only, no `--yes`) |
Use `order-check` to validate a request payload before running `order-send --yes`.
### Execution (live / mutating)
These commands send requests to the live trade server and can place or close
real trades. Both require `--yes` for live execution.
| Command | Description |
| ----------------- | ---------------------------------------------------------------------------------------------------- |
| `order-send` | Send a **raw** trade request directly to MT5 (`--yes` required; expert path — no extra validation) |
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` to preview) |
Use `order-check` (Trading State) to validate funds before running `order-send --yes`.
`close-positions` is the safer high-level alternative that builds correct close
requests automatically. `order-send` is the expert raw path — downstream
applications should prefer dedicated closing helpers or their own risk controls.
### Bulk Collection
| Command | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `collect-history` | Collect rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database (optional cash-event/position views) |
| Command | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `collect-history` | Collect rates, history-orders, and history-deals (ticks opt-in via `--dataset ticks`) for one or more symbols into a single SQLite database (optional cash-event/position views) |
```bash
mt5cli -o history.db collect-history \
@@ -178,16 +190,16 @@ mt5cli -o history.db collect-history \
`collect-history` options:
| Option | Default | Description |
| -------------- | ---------- | --------------------------------------------------------------------------------------------- |
| `--symbol/-s` | _required_ | Symbol to collect (repeat for multiple). |
| `--date-from` | _required_ | Start date in ISO 8601. |
| `--date-to` | _required_ | End date in ISO 8601. |
| `--dataset` | all four | Repeatable: `rates`, `ticks`, `history-orders`, `history-deals`. |
| `--timeframe` | `M1` | Rates timeframe; recorded in a `timeframe` column on the `rates` table. |
| `--flags` | `ALL` | Tick copy flags forwarded to `copy_ticks_range`. |
| `--if-exists` | `fail` | `append`, `replace`, or `fail` when a target table already exists. |
| `--with-views` | off | Add `cash_events` and `positions_reconstructed` views (requires the `history-deals` dataset). |
| Option | Default | Description |
| -------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `--symbol/-s` | _required_ | Symbol to collect (repeat for multiple). |
| `--date-from` | _required_ | Start date in ISO 8601. |
| `--date-to` | _required_ | End date in ISO 8601. |
| `--dataset` | rates, history-orders, history-deals | Repeatable: `rates`, `ticks`, `history-orders`, `history-deals`. Ticks are opt-in: pass `--dataset ticks` to include them. |
| `--timeframe` | `M1` | Rates timeframe; recorded in a `timeframe` column on the `rates` table. |
| `--flags` | `ALL` | Tick copy flags forwarded to `copy_ticks_range`. |
| `--if-exists` | `fail` | `append`, `replace`, or `fail` when a target table already exists. |
| `--with-views` | off | Add `cash_events` and `positions_reconstructed` views (requires the `history-deals` dataset). |
History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `positions_reconstructed` view excludes positions with no closing deal, uses volume-weighted open/close prices, and reports reversal deals (`DEAL_ENTRY_INOUT`) via `volume_reversal` / `reversal_count`.
@@ -217,7 +229,7 @@ See the [History schema diagram](api/history.md#entity-relationship-diagram) for
Browse the API documentation for detailed module information:
- [CLI Module](api/cli.md) - CLI application with export commands
- [CLI Module](api/cli.md) - CLI application with data export and execution commands
- [SDK Module](api/sdk.md) - Programmatic read-only data collection API
- [Utils Module](api/utils.md) - Constants, parameter types, parsers, and export utilities
+50 -36
View File
@@ -1,4 +1,4 @@
"""Command-line interface for MetaTrader 5 data export."""
"""Command-line interface for MetaTrader 5 data and execution utilities."""
from __future__ import annotations
@@ -55,7 +55,12 @@ class _ExportContext:
app = typer.Typer(
name="mt5cli",
help="Export MetaTrader5 data to CSV, JSON, Parquet, or SQLite3.",
help=(
"MT5 data and execution utilities — read market data, inspect account"
" state, and send trade requests. Data commands write to CSV, JSON,"
" Parquet, or SQLite3. Execution commands (order-send, close-positions)"
" require --yes for live mutations."
),
)
_REQUEST_OPTION_HELP = (
@@ -151,7 +156,7 @@ def _callback( # pyright: ignore[reportUnusedFunction]
typer.Option("--log-level", help="Logging level."),
] = LogLevel.WARNING,
) -> None:
"""Configure shared options for all export commands.
"""Configure shared connection and output options.
Raises:
typer.BadParameter: If the output format cannot be determined.
@@ -183,7 +188,7 @@ def _callback( # pyright: ignore[reportUnusedFunction]
# ---------------------------------------------------------------------------
@app.command()
@app.command(rich_help_panel="Data / Export")
def rates_from(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
@@ -210,7 +215,7 @@ def rates_from(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
def rates_from_pos(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
@@ -236,7 +241,7 @@ def rates_from_pos(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
def latest_rates(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
@@ -265,7 +270,7 @@ def latest_rates(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
def rates_range(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
@@ -292,7 +297,7 @@ def rates_range(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
def ticks_from(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
@@ -316,7 +321,7 @@ def ticks_from(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
def ticks_range(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
@@ -340,7 +345,7 @@ def ticks_range(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
def ticks_recent(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
@@ -377,19 +382,19 @@ def ticks_recent(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
def account_info(ctx: typer.Context) -> None:
"""Export account information."""
_export_command(ctx, lambda client: client.account_info())
@app.command()
@app.command(rich_help_panel="Data / Export")
def terminal_info(ctx: typer.Context) -> None:
"""Export terminal information."""
_export_command(ctx, lambda client: client.terminal_info())
@app.command()
@app.command(rich_help_panel="Data / Export")
def symbols(
ctx: typer.Context,
group: Annotated[
@@ -401,7 +406,7 @@ def symbols(
_export_command(ctx, lambda client: client.symbols(group=group))
@app.command()
@app.command(rich_help_panel="Data / Export")
def symbol_info(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
@@ -410,7 +415,7 @@ def symbol_info(
_export_command(ctx, lambda client: client.symbol_info(symbol))
@app.command()
@app.command(rich_help_panel="Data / Export")
def minimum_margins(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
@@ -419,7 +424,7 @@ def minimum_margins(
_export_command(ctx, lambda client: client.minimum_margins(symbol))
@app.command()
@app.command(rich_help_panel="Data / Export")
def orders(
ctx: typer.Context,
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
@@ -433,7 +438,7 @@ def orders(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
def positions(
ctx: typer.Context,
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
@@ -447,7 +452,7 @@ def positions(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
def history_orders(
ctx: typer.Context,
date_from: Annotated[
@@ -477,7 +482,7 @@ def history_orders(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
def history_deals(
ctx: typer.Context,
date_from: Annotated[
@@ -507,7 +512,7 @@ def history_deals(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
def recent_history_deals(
ctx: typer.Context,
hours: Annotated[float, typer.Option(help="Lookback window in hours.")],
@@ -530,25 +535,25 @@ def recent_history_deals(
)
@app.command()
@app.command(rich_help_panel="Data / Export")
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(rich_help_panel="Data / Export")
def version(ctx: typer.Context) -> None:
"""Export MetaTrader5 version information."""
_export_command(ctx, lambda client: client.version())
@app.command()
@app.command(rich_help_panel="Data / Export")
def last_error(ctx: typer.Context) -> None:
"""Export the last error information."""
_export_command(ctx, lambda client: client.last_error())
@app.command()
@app.command(rich_help_panel="Data / Export")
def symbol_info_tick(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
@@ -557,7 +562,7 @@ def symbol_info_tick(
_export_command(ctx, lambda client: client.symbol_info_tick(symbol))
@app.command()
@app.command(rich_help_panel="Data / Export")
def market_book(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
@@ -566,7 +571,7 @@ def market_book(
_export_command(ctx, lambda client: client.market_book(symbol))
@app.command()
@app.command(rich_help_panel="Data / Export")
def order_check(
ctx: typer.Context,
request: Annotated[
@@ -578,7 +583,7 @@ def order_check(
_export_command(ctx, lambda client: client.order_check(request))
@app.command()
@app.command(rich_help_panel="Execution")
def order_send(
ctx: typer.Context,
request: Annotated[
@@ -590,7 +595,13 @@ def order_send(
typer.Option("--yes", help="Confirm the live trade request."),
] = False,
) -> None:
"""Send a trading operation request to the trade server.
"""Send a raw trade request to the trade server (expert path, live execution).
Passes the request JSON directly to MT5 ``order_send``. This is the
low-level expert path it places real trades on the connected account
with no additional validation beyond what MT5 itself performs. Use
``order-check`` first to validate funds sufficiency. Prefer
``close-positions`` for closing open positions. ``--yes`` is required.
Raises:
typer.BadParameter: If --yes is not provided.
@@ -628,7 +639,7 @@ def _execution_results_to_df(results: list[OrderExecutionResult]) -> pd.DataFram
return pd.DataFrame(rows)
@app.command()
@app.command(rich_help_panel="Execution")
def close_positions(
ctx: typer.Context,
symbol: Annotated[
@@ -691,7 +702,7 @@ def close_positions(
_execute_export(ctx, lambda: df)
@app.command()
@app.command(rich_help_panel="Collection")
def collect_history(
ctx: typer.Context,
symbol: Annotated[
@@ -716,7 +727,8 @@ def collect_history(
"--dataset",
help=(
"Dataset to include (repeat for multiple)."
" Defaults to all: rates, ticks, history-orders, history-deals."
" Defaults to rates, history-orders, history-deals."
" Ticks are opt-in: pass --dataset ticks to include them."
),
),
] = None,
@@ -754,10 +766,12 @@ def collect_history(
) -> None:
"""Collect historical datasets into a single SQLite database.
Tables written depend on ``--dataset``: ``rates``, ``ticks``,
``history_orders``, ``history_deals``. History datasets are fetched per
symbol and concatenated. Rates rows carry the requested ``timeframe`` so
appended runs at different timeframes remain distinguishable.
Tables written depend on ``--dataset``: ``rates``, ``history_orders``,
``history_deals`` by default. ``ticks`` are opt-in: pass
``--dataset ticks`` to include them (tick data grows the database quickly).
History datasets are fetched per symbol and concatenated. Rates rows carry
the requested ``timeframe`` so appended runs at different timeframes remain
distinguishable.
With ``--with-views`` (requires the ``history-deals`` dataset), optional
views ``cash_events`` and ``positions_reconstructed`` are derived from
@@ -773,7 +787,7 @@ def collect_history(
" Use a .db/.sqlite/.sqlite3 extension or --format sqlite3."
)
raise typer.BadParameter(msg)
datasets = set(dataset) if dataset else set(Dataset)
datasets = set(dataset) if dataset is not None else None
sdk.collect_history(
output=export_ctx.output,
symbols=symbol,
+9 -3
View File
@@ -30,6 +30,11 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = TIMEFRAME_NAMES
DEFAULT_HISTORY_DATASETS: frozenset[Dataset] = frozenset({
Dataset.rates,
Dataset.history_orders,
Dataset.history_deals,
})
_HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
Dataset.rates: DEDUP_KEYS[DataKind.rates],
@@ -62,11 +67,12 @@ def resolve_history_datasets(datasets: set[Dataset] | None) -> set[Dataset]:
"""Resolve configured history datasets.
Returns:
All supported datasets when ``datasets`` is None, otherwise the
configured selection (which may be empty).
``DEFAULT_HISTORY_DATASETS`` (rates, history-orders, history-deals)
when ``datasets`` is None, otherwise the configured selection (which
may be empty or explicitly include ``Dataset.ticks``).
"""
if datasets is None:
return set(Dataset)
return set(DEFAULT_HISTORY_DATASETS)
return set(datasets)
+7 -4
View File
@@ -981,7 +981,8 @@ def update_history( # noqa: PLR0913
client: Connected MT5 data client.
output: SQLite database path.
symbols: Symbols to update.
datasets: Datasets to include (defaults to all).
datasets: Datasets to include (defaults to rates, history-orders,
history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
timeframes: Rate timeframes to update (defaults to all fixed MT5
timeframes when None).
flags: Tick copy flags as integer or name (e.g. ``ALL``).
@@ -1110,7 +1111,8 @@ class ThrottledHistoryUpdater:
Args:
output: SQLite database path.
datasets: Datasets to include (defaults to all).
datasets: Datasets to include (defaults to rates, history-orders,
history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
timeframes: Rate timeframes to update (defaults to all fixed MT5
timeframes).
flags: Tick copy flags as integer or name (e.g. ``ALL``).
@@ -1242,7 +1244,8 @@ def collect_history(
symbols: Symbols to collect.
date_from: Start date.
date_to: End date.
datasets: Datasets to include (defaults to all).
datasets: Datasets to include (defaults to rates, history-orders,
history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
timeframe: Rates timeframe as integer or name (e.g. ``M1``).
flags: Tick copy flags as integer or name (e.g. ``ALL``).
if_exists: Behavior when a target table already exists.
@@ -1251,7 +1254,7 @@ def collect_history(
"""
start = _require_datetime(date_from)
end = _require_datetime(date_to)
selected = datasets if datasets is not None else set(Dataset)
selected = resolve_history_datasets(datasets)
tf = _coerce_timeframe(timeframe)
tick_flags = _coerce_tick_flags(flags)
mt5_config = config or build_config()
+10 -2
View File
@@ -15,6 +15,7 @@ from .exceptions import Mt5OperationError
from .history import drop_forming_rate_bar
from .sdk import build_config
from .utils import coerce_login as _coerce_login
from .utils import parse_timeframe
if TYPE_CHECKING:
from collections.abc import Iterator, Mapping, Sequence
@@ -1624,11 +1625,18 @@ def fetch_latest_closed_rates_for_trading_client(
if count <= 0:
msg = "count must be positive."
raise ValueError(msg)
timeframe = parse_timeframe(granularity)
fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
if not callable(fetch_method):
copy_method = getattr(client, "copy_rates_from_pos_as_df", None)
if callable(fetch_method):
fetched = fetch_method(symbol, granularity, count + 1)
elif callable(copy_method):
fetched = copy_method(
symbol=symbol, timeframe=timeframe, start_pos=0, count=count + 1
)
else:
msg = "MT5 trading client cannot fetch rate data."
raise Mt5OperationError(msg)
fetched = fetch_method(symbol, granularity, count + 1)
if not isinstance(fetched, pd.DataFrame):
msg = (
f"Malformed rate data for {symbol!r} at granularity {granularity!r}: "
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mt5cli"
version = "1.0.0"
version = "1.0.3"
description = "Generic MT5 data and execution infrastructure for Python applications"
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
+103 -6
View File
@@ -740,6 +740,66 @@ class TestCommands:
assert "must be a JSON object" in normalize_cli_output(result.output)
# ---------------------------------------------------------------------------
# Help text / scope tests
# ---------------------------------------------------------------------------
class TestHelpText:
"""Tests verifying CLI help text matches the documented scope."""
def test_top_level_help_mentions_execution(self) -> None:
"""Top-level help must describe execution utilities, not export only."""
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
output = normalize_cli_output(result.output)
assert "execution" in output.lower()
def test_top_level_help_has_execution_panel(self) -> None:
"""Top-level help must show an Execution command group."""
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "Execution" in result.output
def test_top_level_help_has_data_export_panel(self) -> None:
"""Top-level help must show a Data / Export command group."""
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "Data / Export" in result.output
def test_order_send_help_mentions_expert_and_raw(self) -> None:
"""order-send help must communicate it is the expert raw-request path."""
result2 = runner.invoke(
app,
["-o", "out.csv", "order-send", "--help"],
)
assert result2.exit_code == 0
output = normalize_cli_output(result2.output)
assert "raw" in output.lower()
assert "expert" in output.lower()
def test_order_send_help_mentions_live_execution(self) -> None:
"""order-send help must warn about live execution."""
result = runner.invoke(
app,
["-o", "out.csv", "order-send", "--help"],
)
assert result.exit_code == 0
output = normalize_cli_output(result.output)
assert "live" in output.lower()
def test_close_positions_help_mentions_dry_run_and_yes(self) -> None:
"""close-positions help must document both safety gates."""
result = runner.invoke(
app,
["-o", "out.csv", "close-positions", "--help"],
)
assert result.exit_code == 0
output = normalize_cli_output(result.output)
assert "--dry-run" in output
assert "--yes" in output
# ---------------------------------------------------------------------------
# close-positions command
# ---------------------------------------------------------------------------
@@ -1238,12 +1298,12 @@ class TestCollectHistory:
"""Create a mocked Mt5DataClient with history-style DataFrames."""
return _build_history_client(mocker)
def test_collect_history_writes_all_tables(
def test_collect_history_writes_default_tables(
self,
tmp_path: Path,
history_client: MagicMock,
) -> None:
"""Test that collect-history writes rates, ticks, and history tables."""
"""Test that collect-history default excludes ticks."""
output = tmp_path / "history.db"
result = runner.invoke(
app,
@@ -1263,8 +1323,42 @@ class TestCollectHistory:
)
assert result.exit_code == 0, result.output
assert history_client.copy_rates_range_as_df.call_count == 2
assert history_client.copy_ticks_range_as_df.call_count == 2
history_client.copy_ticks_range_as_df.assert_any_call(
assert history_client.copy_ticks_range_as_df.call_count == 0
with sqlite3.connect(output) as conn:
tables = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'",
).fetchall()
}
assert {"rates", "history_orders", "history_deals"} <= tables
assert "ticks" not in tables
def test_collect_history_explicit_ticks_dataset(
self,
tmp_path: Path,
history_client: MagicMock,
) -> None:
"""Test that --dataset ticks writes the ticks table with the correct flags."""
output = tmp_path / "history.db"
result = runner.invoke(
app,
[
"-o",
str(output),
"collect-history",
"--symbol",
"EURUSD",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
"--dataset",
"ticks",
],
)
assert result.exit_code == 0, result.output
history_client.copy_ticks_range_as_df.assert_called_once_with(
symbol="EURUSD",
date_from=datetime(2024, 1, 1, tzinfo=UTC),
date_to=datetime(2024, 2, 1, tzinfo=UTC),
@@ -1277,7 +1371,8 @@ class TestCollectHistory:
"SELECT name FROM sqlite_master WHERE type='table'",
).fetchall()
}
assert {"rates", "ticks", "history_orders", "history_deals"} <= tables
assert "ticks" in tables
assert "rates" not in tables
def test_collect_history_history_fetched_per_symbol(
self,
@@ -1460,7 +1555,7 @@ class TestCollectHistory:
tmp_path: Path,
history_client: MagicMock,
) -> None:
"""Test that --flags defaults to ALL for ticks."""
"""Test that --flags defaults to ALL when --dataset ticks is explicit."""
output = tmp_path / "history.db"
result = runner.invoke(
app,
@@ -1474,6 +1569,8 @@ class TestCollectHistory:
"2024-01-01",
"--date-to",
"2024-02-01",
"--dataset",
"ticks",
],
)
assert result.exit_code == 0, result.output
+16 -2
View File
@@ -19,6 +19,7 @@ from pdmt5 import TIMEFRAME_MAP
from mt5cli import history
from mt5cli.history import (
DEFAULT_HISTORY_DATASETS,
DEFAULT_HISTORY_TIMEFRAMES,
DedupScope,
RateTarget,
@@ -547,10 +548,23 @@ class TestResolveHistorySettings:
"""Tests for history dataset and timeframe resolution."""
def test_resolve_history_datasets_defaults_and_empty(self) -> None:
"""Test dataset resolution distinguishes None from empty selection."""
assert resolve_history_datasets(None) == set(Dataset)
"""Test dataset resolution excludes ticks by default."""
resolved = resolve_history_datasets(None)
assert resolved == set(DEFAULT_HISTORY_DATASETS)
assert Dataset.ticks not in resolved
assert {
Dataset.rates,
Dataset.history_orders,
Dataset.history_deals,
} == resolved
assert resolve_history_datasets(set()) == set()
def test_resolve_history_datasets_explicit_ticks(self) -> None:
"""Test that explicit ticks selection is honored."""
assert resolve_history_datasets({Dataset.ticks}) == {Dataset.ticks}
all_ds = resolve_history_datasets(set(Dataset))
assert Dataset.ticks in all_ds
def test_resolve_history_timeframes_defaults(self) -> None:
"""Test default timeframes include all fixed MT5 values."""
resolved = resolve_history_timeframes(None)
+65 -4
View File
@@ -618,12 +618,12 @@ class TestCollectHistory:
"""Create a mocked Mt5DataClient with history-style DataFrames."""
return _build_history_client(mocker)
def test_collect_history_writes_all_tables(
def test_collect_history_writes_default_tables(
self,
tmp_path: Path,
history_client: MagicMock,
) -> None:
"""Test that collect_history writes rates, ticks, and history tables."""
"""Test that collect_history default excludes ticks."""
output = tmp_path / "history.db"
collect_history(
output,
@@ -632,7 +632,7 @@ class TestCollectHistory:
"2024-02-01",
)
assert history_client.copy_rates_range_as_df.call_count == 2
assert history_client.copy_ticks_range_as_df.call_count == 2
assert history_client.copy_ticks_range_as_df.call_count == 0
with sqlite3.connect(output) as conn:
tables = {
row[0]
@@ -640,7 +640,34 @@ class TestCollectHistory:
"SELECT name FROM sqlite_master WHERE type='table'",
).fetchall()
}
assert {"rates", "ticks", "history_orders", "history_deals"} <= tables
assert {"rates", "history_orders", "history_deals"} <= tables
assert "ticks" not in tables
def test_collect_history_explicit_ticks_dataset(
self,
tmp_path: Path,
history_client: MagicMock,
) -> None:
"""Test that explicit datasets={Dataset.ticks} writes the ticks table."""
output = tmp_path / "history.db"
collect_history(
output,
["EURUSD", "GBPUSD"],
"2024-01-01",
"2024-02-01",
datasets={Dataset.ticks},
)
assert history_client.copy_ticks_range_as_df.call_count == 2
assert history_client.copy_rates_range_as_df.call_count == 0
with sqlite3.connect(output) as conn:
tables = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'",
).fetchall()
}
assert "ticks" in tables
assert "rates" not in tables
def test_collect_history_with_views(
self,
@@ -1066,6 +1093,40 @@ class TestUpdateHistory:
after = datetime.now(UTC)
assert before <= captured["end"] <= after
def test_update_history_default_datasets_exclude_ticks(
self,
connected_client: MagicMock,
mocker: MockerFixture,
tmp_path: Path,
) -> None:
"""Test update_history with datasets=None does not collect ticks."""
datasets_written: list[set[Dataset]] = []
def capture(
*args: object,
**_kwargs: object,
) -> tuple[set[Dataset], dict[Dataset, set[str]]]:
datasets_written.append(args[3]) # type: ignore[arg-type]
return set(), {}
mocker.patch("mt5cli.sdk.write_incremental_datasets", side_effect=capture)
update_history(
client=connected_client,
output=tmp_path / "default-datasets.db",
symbols=["EURUSD"],
datasets=None,
timeframes=["M1"],
lookback_hours=1,
date_to=datetime(2024, 1, 1, tzinfo=UTC),
)
assert len(datasets_written) == 1
assert Dataset.ticks not in datasets_written[0]
assert {
Dataset.rates,
Dataset.history_orders,
Dataset.history_deals,
} == datasets_written[0]
class TestRecentTicks:
"""Tests for recent_ticks helper."""
+88
View File
@@ -3198,6 +3198,71 @@ class TestFetchLatestClosedRatesForTradingClient:
count=2,
)
def test_copy_rates_from_pos_fallback_drops_forming_bar(self) -> None:
"""Regression: client with only copy_rates_from_pos_as_df works end-to-end."""
client = MagicMock(spec=["copy_rates_from_pos_as_df"])
client.copy_rates_from_pos_as_df.return_value = pd.DataFrame(
{
"time": [1700000000, 1700000060, 1700000120],
"close": [1.0, 1.1, 1.2],
},
)
result = fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
client.copy_rates_from_pos_as_df.assert_called_once_with(
symbol="EURUSD", timeframe=1, start_pos=0, count=3
)
assert list(result["close"]) == [1.0, 1.1]
assert list(result["time"]) == [1700000000, 1700000060]
def test_copy_rates_from_pos_fallback_resolves_granularity(self) -> None:
"""Fallback path resolves granularity string to integer timeframe."""
client = MagicMock(spec=["copy_rates_from_pos_as_df"])
client.copy_rates_from_pos_as_df.return_value = pd.DataFrame(
{"time": [1, 2, 3, 4], "close": [1.0, 1.1, 1.2, 1.3]},
)
fetch_latest_closed_rates_for_trading_client(
client, symbol="USDJPY", granularity="H1", count=3
)
call_kwargs = client.copy_rates_from_pos_as_df.call_args.kwargs
assert call_kwargs["symbol"] == "USDJPY"
assert call_kwargs["timeframe"] == 16385
assert call_kwargs["start_pos"] == 0
assert call_kwargs["count"] == 4
def test_copy_rates_from_pos_fallback_returns_count_closed_rows(self) -> None:
"""Fallback path trims to exactly count closed rows after forming-bar drop."""
client = MagicMock(spec=["copy_rates_from_pos_as_df"])
client.copy_rates_from_pos_as_df.return_value = pd.DataFrame(
{"time": list(range(6)), "close": [float(i) for i in range(6)]},
)
result = fetch_latest_closed_rates_for_trading_client(
client, symbol="EURUSD", granularity="M1", count=4
)
assert len(result) == 4
assert list(result["close"]) == [1.0, 2.0, 3.0, 4.0]
def test_copy_rates_from_pos_fallback_raises_on_invalid_granularity(self) -> None:
"""Invalid granularity raises ValueError before calling the fallback method."""
client = MagicMock(spec=["copy_rates_from_pos_as_df"])
with pytest.raises(ValueError, match="Invalid timeframe"):
fetch_latest_closed_rates_for_trading_client(
client, symbol="EURUSD", granularity="BADGRAN", count=1
)
client.copy_rates_from_pos_as_df.assert_not_called()
def test_raises_when_trading_client_cannot_fetch_rates(self) -> None:
"""Test missing rate-fetch methods raise Mt5TradingError."""
client = MagicMock(spec=[])
@@ -3528,6 +3593,29 @@ class TestFetchLatestClosedRatesIndexed:
assert "close" in result.columns
assert isinstance(result.index, pd.DatetimeIndex)
def test_copy_rates_from_pos_fallback_produces_utc_datetime_index(self) -> None:
"""Fallback path via copy_rates_from_pos_as_df produces a UTC DatetimeIndex."""
client = MagicMock(spec=["copy_rates_from_pos_as_df"])
client.copy_rates_from_pos_as_df.return_value = pd.DataFrame(
{
"time": [1700000000, 1700003600, 1700007200],
"close": [1.1, 1.2, 1.3],
},
)
result = fetch_latest_closed_rates_indexed(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
assert isinstance(result.index, pd.DatetimeIndex)
assert result.index.name == "time"
assert str(result.index.tz) == "UTC"
assert "time" not in result.columns
assert list(result["close"]) == [1.1, 1.2]
class TestExtractTickPrice:
"""Tests for the public extract_tick_price helper."""
Generated
+2 -2
View File
@@ -358,7 +358,7 @@ name = "metatrader5"
version = "5.0.5640"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "sys_platform == 'win32'" },
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a0/3b764c6743ef601ff12f7d8d62ca5768eb25e90d3758ac7e1d08e667af85/metatrader5-5.0.5640-cp311-cp311-win_amd64.whl", hash = "sha256:4057255f2d63138a3ea1a5d492715038a71d3177cad793fca97a5c58771b4eb1", size = 48091, upload-time = "2026-02-20T23:31:12.289Z" },
@@ -487,7 +487,7 @@ wheels = [
[[package]]
name = "mt5cli"
version = "1.0.0"
version = "1.0.3"
source = { editable = "." }
dependencies = [
{ name = "click" },