Compare commits

...

12 Commits

Author SHA1 Message Date
Daichi Narushima da74c11087 Add collect-history command for bulk data collection (#14)
* Add collect-history command for bulk SQLite export

Bundles rates, ticks, history-orders, and history-deals for one or more
symbols into a single SQLite database. Tick collection uses
copy_ticks_range_as_df with a --flags option defaulting to ALL. With
--with-views, optional cash_events and positions_reconstructed views are
derived from history_deals when the required columns are present.

* Extend collect-history with datasets, if-exists, timeframe, view fixes

- Fetch history-orders and history-deals per symbol so --symbol applies
  consistently across all four datasets.
- Add repeatable --dataset (rates, ticks, history-orders, history-deals)
  so ticks are no longer required and any subset can be collected.
- Add --if-exists append|replace|fail to control SQLite table conflict
  behavior instead of hard-coding replace.
- Record the requested timeframe in a timeframe column on the rates
  table so appended runs at different timeframes stay distinguishable.
- Fix positions_reconstructed to exclude positions with no closing
  deals, use volume-weighted open/close prices, and report reversal
  deals (DEAL_ENTRY_INOUT) via volume_reversal / reversal_count without
  contributing to weighted prices.
- Update tests, README, docs, and skill to match.

* Address collect-history review feedback

* Stream collect-history writes per symbol

* Address PR cleanup for collect-history

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-29 01:22:59 +09:00
Daichi Narushima c45efb953c Update local QA skill workflow (#13) 2026-05-25 02:12:28 +09:00
dceoy c1eea3fa3d Update .github/workflows/ci.yml 2026-05-25 01:48:43 +09:00
github-actions[bot] 62e5f438f0 Merge pull request #12 from dceoy/dependabot/uv/uv-d665ee01e3
Bump idna from 3.13 to 3.15 in the uv group across 1 directory
2026-05-19 21:12:38 +00:00
dependabot[bot] 50f62bca73 Bump idna from 3.13 to 3.15 in the uv group across 1 directory
Bumps the uv group with 1 update in the / directory: [idna](https://github.com/kjd/idna).


Updates `idna` from 3.13 to 3.15
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](https://github.com/kjd/idna/compare/v3.13...v3.15)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 21:11:32 +00:00
github-actions[bot] 0b7dfdc621 Merge pull request #11 from dceoy/dependabot/uv/uv-ab67d3f053
Bump pymdown-extensions from 10.21.2 to 10.21.3 in the uv group across 1 directory
2026-05-19 20:48:47 +00:00
dependabot[bot] bab776e700 Bump pymdown-extensions in the uv group across 1 directory
Bumps the uv group with 1 update in the / directory: [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions).


Updates `pymdown-extensions` from 10.21.2 to 10.21.3
- [Release notes](https://github.com/facelessuser/pymdown-extensions/releases)
- [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.21.2...10.21.3)

---
updated-dependencies:
- dependency-name: pymdown-extensions
  dependency-version: 10.21.3
  dependency-type: direct:development
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 20:47:23 +00:00
github-actions[bot] 9bf9a6a72c Merge pull request #10 from dceoy/dependabot/uv/uv-c30c77f42d
Bump urllib3 from 2.6.3 to 2.7.0 in the uv group across 1 directory
2026-05-11 18:22:00 +00:00
dependabot[bot] d7594ddc43 Bump urllib3 from 2.6.3 to 2.7.0 in the uv group across 1 directory
Bumps the uv group with 1 update in the / directory: [urllib3](https://github.com/urllib3/urllib3).


Updates `urllib3` from 2.6.3 to 2.7.0
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 18:20:57 +00:00
Daichi Narushima b8ce76c9d6 Add CLI commands for remaining pdmt5.dataframe methods (#9)
* Add CLI commands for remaining pdmt5.dataframe methods

Add the following subcommands so the CLI fully covers pdmt5.dataframe's
public *_as_df methods:

- version
- last-error
- symbol-info-tick
- market-book
- order-check (request via inline JSON or @path/to/file.json)
- order-send  (request via inline JSON or @path/to/file.json)

Also export a new parse_request helper for parsing JSON order requests,
add tests for every new command, and update the README and docs command
tables.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump version to 0.2.0

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* Address PR review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Harden CLI error assertions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-26 21:50:19 +09:00
dceoy 5750625e14 Remove python-package-release job 2026-04-26 06:21:18 +09:00
Daichi Narushima 2cf4d1cf6b Update GitHub workflows to use new reusable workflow parameters (#8) 2026-04-23 06:01:54 +09:00
11 changed files with 1742 additions and 152 deletions
+18 -21
View File
@@ -1,25 +1,22 @@
# local-qa ---
name: local-qa
description: Run local QA including formatting, linting, and testing for the repository. Use whenever any file has been updated.
disable-model-invocation: false
---
Run local QA checks (format, lint, test) on the repository. # Local QA (format, lint, and test)
## When to use Run the local QA script `scripts/qa.sh` in this skill.
After making changes to repository files, run `scripts/qa.sh` to validate formatting, linting, and tests. ## Procedure
## Steps - Execute the script exactly as shown above when this skill is triggered.
- Capture and summarize key output (success/failure, major warnings, and any files modified).
1. Execute `scripts/qa.sh` and capture the results. - If the script fails due to missing tooling (`command not found`, missing executable, or equivalent), install the missing tool(s) and rerun `./scripts/qa.sh`.
2. Report successes, failures, warnings, and any modified files. - Install tools using this order of preference:
1. Use the project's package manager when applicable (`uv`/`poetry` for Python, package manager scripts/dependencies for Node.js).
## If tools are missing 2. Use a system package manager (`brew` on macOS, `apt` on Debian/Ubuntu) when project-local install is not applicable.
3. Use language-specific installers as fallback (`pipx`/`pip`, `npm`, `go install`, etc.).
Install them following this priority order: - If multiple tools are missing, repeat install -> rerun until QA completes or you hit a blocker.
- If installation fails or requires unavailable privileges, report what was attempted, the exact failure, and stop.
1. Project package managers (`uv`, `poetry`, npm scripts) - Do not run unrelated commands; only run commands needed for QA and missing-tool installation.
2. System package managers (`brew`, `apt`)
3. Language-specific installers (`pipx`, `pip`, `npm`, `go install`)
## Constraints
- Only execute QA and tool-installation commands.
- If installation fails or requires unavailable privileges, report the attempt and exact failure, then stop.
+11 -4
View File
@@ -13,7 +13,14 @@ uv run pytest
npx -y prettier --write './**/*.md' npx -y prettier --write './**/*.md'
# GitHub Actions # GitHub Actions
zizmor --fix=safe .github/workflows case "${OSTYPE}" in
git ls-files -z -- '.github/workflows/*.yml' | xargs -0 -t actionlint darwin* | linux* )
git ls-files -z -- '.github/workflows/*.yml' | xargs -0 -t yamllint -d '{"extends": "relaxed", "rules": {"line-length": "disable"}}' zizmor --fix=safe .github/workflows
checkov --framework=all --output=github_failed_only --directory=. git ls-files -z -- '.github/workflows/*.yml' | xargs -0 -t actionlint
git ls-files -z -- '.github/workflows/*.yml' | xargs -0 -t yamllint -d '{"extends": "relaxed", "rules": {"line-length": "disable"}}'
checkov --framework=all --output=github_failed_only --directory=.
;;
* )
echo "GitHub Actions linting is only supported on Linux and macOS."
;;
esac
+1 -15
View File
@@ -35,7 +35,7 @@ jobs:
|| (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test') || (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test')
permissions: permissions:
contents: read contents: read
uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-lint-and-scan.yml@main # zizmor: ignore[unpinned-uses] uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-lint-and-scan.yml@main # zizmor: ignore[unpinned-uses]
with: with:
package-path: . package-path: .
runs-on: windows-latest runs-on: windows-latest
@@ -59,23 +59,9 @@ jobs:
uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-mkdocs-gh-deploy.yml@main # zizmor: ignore[unpinned-uses] uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-mkdocs-gh-deploy.yml@main # zizmor: ignore[unpinned-uses]
with: with:
package-path: . package-path: .
mkdocs-theme: material
runs-on: ubuntu-slim runs-on: ubuntu-slim
secrets: secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
python-package-release:
if: >
github.event_name == 'push'
|| (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test')
permissions:
contents: write
id-token: write
uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses]
with:
package-path: .
create-releases: false
secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
dependabot-auto-merge: dependabot-auto-merge:
if: > if: >
github.event_name == 'pull_request' && github.actor == 'dependabot[bot]' github.event_name == 'pull_request' && github.actor == 'dependabot[bot]'
+6 -56
View File
@@ -9,84 +9,34 @@ defaults:
shell: bash -euo pipefail {0} shell: bash -euo pipefail {0}
working-directory: . working-directory: .
jobs: jobs:
build: build-and-release:
permissions: permissions:
contents: write contents: write
id-token: write id-token: write
uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses] uses: dceoy/gh-actions-for-devops/.github/workflows/python-package-release-on-pypi-and-github.yml@main # zizmor: ignore[unpinned-uses]
with: with:
package-path: . package-path: .
create-releases: false create-github-release: true
publish-to-pypi: false
secrets: secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
github-release:
name: Sign the Python 🐍 distribution 📦 with Sigstore and upload them to GitHub Release
if: >
startsWith(github.ref, 'refs/tags/')
needs:
- build
runs-on: ubuntu-latest
permissions:
contents: write # IMPORTANT: mandatory for making GitHub Releases
id-token: write # IMPORTANT: mandatory for sigstore
steps:
- name: Download all the dists
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.build.outputs.distribution-artifact-name }}
path: dist/
- name: Validate the version-tag consistency
env:
TAG_NAME: ${{ github.ref_name }}
NEEDS_BUILD_OUTPUTS_PROJECT_NAME: ${{ needs.build.outputs.project-name }}
run: |
v="$( \
find dist -type f -name "${NEEDS_BUILD_OUTPUTS_PROJECT_NAME}-*" -exec basename {} \; \
| head -n 1 \
| cut -d '-' -f 2 \
)"
v="${v%.tar.gz}"
if [[ "${TAG_NAME}" != "${v}" ]] && [[ "${TAG_NAME}" != "v${v}" ]]; then
echo "The tag (${TAG_NAME}) is inconsistent with the version (${v})." && exit 1
fi
- name: Sign the dists with Sigstore
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
with:
inputs: >-
./dist/*.whl
- name: Create GitHub Release
env:
REPOSITORY: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} # zizmor: ignore[secrets-outside-env] caller-provided secret
run: |
gh release create "${GITHUB_REF_NAME}" --repo "${REPOSITORY}" --generate-notes --verify-tag
- name: Upload artifact signatures to GitHub Release
env:
REPOSITORY: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} # zizmor: ignore[secrets-outside-env] caller-provided secret
# Upload to GitHub Release using the `gh` CLI.
# `dist/` contains the built packages, and the
# sigstore-produced signatures and certificates.
run: |
gh release upload "${GITHUB_REF_NAME}" dist/** --repo "${REPOSITORY}"
publish-to-pypi: publish-to-pypi:
name: Publish the Python 🐍 distribution 📦 to PyPI name: Publish the Python 🐍 distribution 📦 to PyPI
if: > if: >
startsWith(github.ref, 'refs/tags/') startsWith(github.ref, 'refs/tags/')
needs: needs:
- build - build-and-release
- github-release
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: environment:
name: pypi name: pypi
url: https://pypi.org/p/${{ needs.build.outputs.project-name }} url: https://pypi.org/p/${{ needs.build-and-release.outputs.project-name }}
permissions: permissions:
id-token: write # IMPORTANT: mandatory for trusted publishing id-token: write # IMPORTANT: mandatory for trusted publishing
steps: steps:
- name: Download all the dists - name: Download all the dists
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: ${{ needs.build.outputs.distribution-artifact-name }} name: ${{ needs.build-and-release.outputs.distribution-artifact-name }}
path: dist/ path: dist/
- name: Publish distribution 📦 to PyPI - name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
+38 -15
View File
@@ -50,21 +50,44 @@ 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 | | `rates-range` | Export rates for a date range |
| `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 |
| `account-info` | Export account information | | `account-info` | Export account information |
| `terminal-info` | Export terminal 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 |
| `orders` | Export active orders | | `symbols` | Export symbol list |
| `positions` | Export open positions | | `symbol-info` | Export symbol details |
| `history-orders` | Export historical orders | | `symbol-info-tick` | Export the last tick for a symbol |
| `history-deals` | Export historical deals | | `market-book` | Export market depth (order book) |
| `orders` | Export active orders |
| `positions` | Export open positions |
| `history-orders` | Export historical orders |
| `history-deals` | Export historical deals |
| `order-check` | Check funds sufficiency for a trade request |
| `order-send` | Send a trade request to the trade server (`--yes` required) |
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
Use `order-check` to validate a request payload before running `order-send --yes`.
### `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`.
```bash
mt5cli -o history.db collect-history \
--symbol EURUSD --symbol GBPUSD \
--date-from 2024-01-01 --date-to 2024-02-01 \
--dataset rates --dataset history-deals \
--timeframe M1 --flags ALL --if-exists append --with-views
```
History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `rates` table records the requested `timeframe` so appended runs at different timeframes remain distinguishable. The `positions_reconstructed` view aggregates trade deals by `position_id`, excludes positions without closing deals, and uses volume-weighted open/close prices; reversal deals (`DEAL_ENTRY_INOUT`) are reported via `volume_reversal` / `reversal_count` columns and do not contribute to the weighted prices.
## Requirements ## Requirements
+49 -12
View File
@@ -61,21 +61,58 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
### Information ### Information
| Command | Description | | Command | Description |
| --------------- | --------------------------- | | ------------------ | --------------------------------------- |
| `account-info` | Export account information | | `account-info` | Export account information |
| `terminal-info` | Export terminal 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 |
| `symbols` | Export symbol list |
| `symbol-info` | Export symbol details |
| `symbol-info-tick` | Export the last tick for a symbol |
| `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 |
| `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`.
### 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) |
```bash
mt5cli -o history.db collect-history \
--symbol EURUSD --symbol GBPUSD \
--date-from 2024-01-01 --date-to 2024-02-01 \
--dataset rates --dataset history-deals \
--timeframe M1 --flags ALL --if-exists append --with-views
```
`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). |
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`.
## Global Options ## Global Options
+702 -2
View File
@@ -2,13 +2,14 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import sqlite3 import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import StrEnum from enum import StrEnum
from pathlib import Path # noqa: TC003 from pathlib import Path
from typing import TYPE_CHECKING, Annotated, cast from typing import TYPE_CHECKING, Annotated, Any, TypeGuard, cast
import click import click
import typer import typer
@@ -55,6 +56,19 @@ TICK_FLAG_MAP: dict[str, int] = {
"TRADE": 4, "TRADE": 4,
} }
_TRADE_DEAL_TYPES: tuple[int, int] = (0, 1)
_TRADE_DEAL_TYPES_SQL = f"({', '.join(str(value) for value in _TRADE_DEAL_TYPES)})"
_POSITIONS_VIEW_REQUIRED_COLUMNS: frozenset[str] = frozenset({
"position_id",
"symbol",
"time",
"type",
"entry",
"volume",
"price",
"profit",
})
_FORMAT_EXTENSIONS: dict[str, str] = { _FORMAT_EXTENSIONS: dict[str, str] = {
".csv": "csv", ".csv": "csv",
".json": "json", ".json": "json",
@@ -88,6 +102,31 @@ class LogLevel(StrEnum):
ERROR = "ERROR" ERROR = "ERROR"
class Dataset(StrEnum):
"""Datasets supported by the ``collect-history`` command."""
rates = "rates"
ticks = "ticks"
history_orders = "history-orders"
history_deals = "history-deals"
class IfExists(StrEnum):
"""SQLite table conflict behavior for the ``collect-history`` command."""
APPEND = "append"
REPLACE = "replace"
FAIL = "fail"
_DATASET_TABLE_NAMES: dict[Dataset, str] = {
Dataset.rates: "rates",
Dataset.ticks: "ticks",
Dataset.history_orders: "history_orders",
Dataset.history_deals: "history_deals",
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Click parameter types # Click parameter types
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -180,9 +219,37 @@ class _TickFlagsType(click.ParamType):
self.fail(str(exc), param, ctx) self.fail(str(exc), param, ctx)
class _RequestType(click.ParamType):
"""Click parameter type for JSON order requests."""
name = "REQUEST"
def convert(
self,
value: object,
param: click.Parameter | None,
ctx: click.Context | None,
) -> dict[str, Any]:
"""Convert a raw CLI value to an order request dictionary.
Args:
value: Raw value from the command line.
param: Click parameter instance.
ctx: Click context.
Returns:
Parsed request dictionary.
"""
try:
return parse_request(str(value))
except ValueError as exc:
self.fail(str(exc), param, ctx)
DATETIME_TYPE = _DateTimeType() DATETIME_TYPE = _DateTimeType()
TIMEFRAME_TYPE = _TimeframeType() TIMEFRAME_TYPE = _TimeframeType()
TICK_FLAGS_TYPE = _TickFlagsType() TICK_FLAGS_TYPE = _TickFlagsType()
REQUEST_TYPE = _RequestType()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Export context # Export context
@@ -342,6 +409,43 @@ def parse_tick_flags(value: str) -> int:
raise ValueError(msg) from None raise ValueError(msg) from None
def _is_request_dict(value: object) -> TypeGuard[dict[str, Any]]:
return isinstance(value, dict)
def parse_request(value: str) -> dict[str, Any]:
"""Parse a JSON-formatted order request string or file reference.
Args:
value: JSON object string, or '@path' to read JSON from a file.
Returns:
Parsed request dictionary.
Raises:
ValueError: If the request file cannot be read or the value is not a
JSON object.
"""
if value.startswith("@"):
path = Path(value[1:])
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
msg = f"Failed to read JSON request file '{path}': {exc}"
raise ValueError(msg) from exc
else:
text = value
try:
parsed: object = json.loads(text)
except json.JSONDecodeError as exc:
msg = f"Invalid JSON request: {exc}"
raise ValueError(msg) from exc
if not _is_request_dict(parsed):
msg = "Order request must be a JSON object."
raise ValueError(msg)
return parsed
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Typer application # Typer application
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -351,6 +455,10 @@ app = typer.Typer(
help="Export MetaTrader5 data to CSV, JSON, Parquet, or SQLite3.", help="Export MetaTrader5 data to CSV, JSON, Parquet, or SQLite3.",
) )
_REQUEST_OPTION_HELP = (
"Order request as a JSON object string, or '@path' to load JSON from a file."
)
def _get_export_context(ctx: typer.Context) -> _ExportContext: def _get_export_context(ctx: typer.Context) -> _ExportContext:
return cast("_ExportContext", ctx.obj) return cast("_ExportContext", ctx.obj)
@@ -747,6 +855,598 @@ def history_deals(
) )
@app.command()
def version(ctx: typer.Context) -> None:
"""Export MetaTrader5 version information."""
_execute_export(ctx, lambda c: c.version_as_df())
@app.command()
def last_error(ctx: typer.Context) -> None:
"""Export the last error information."""
_execute_export(ctx, lambda c: c.last_error_as_df())
@app.command()
def symbol_info_tick(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
) -> None:
"""Export the last tick for a symbol."""
_execute_export(
ctx,
lambda c: c.symbol_info_tick_as_df(symbol=symbol),
)
@app.command()
def market_book(
ctx: typer.Context,
symbol: Annotated[str, typer.Option(help="Symbol name.")],
) -> None:
"""Export market depth (order book) for a symbol."""
_execute_export(
ctx,
lambda c: c.market_book_get_as_df(symbol=symbol),
)
@app.command()
def order_check(
ctx: typer.Context,
request: Annotated[
dict[str, Any],
typer.Option(click_type=REQUEST_TYPE, help=_REQUEST_OPTION_HELP),
],
) -> None:
"""Check funds sufficiency for a trading operation."""
_execute_export(
ctx,
lambda c: c.order_check_as_df(request=request),
)
@app.command()
def order_send(
ctx: typer.Context,
request: Annotated[
dict[str, Any],
typer.Option(click_type=REQUEST_TYPE, help=_REQUEST_OPTION_HELP),
],
yes: Annotated[
bool,
typer.Option("--yes", help="Confirm the live trade request."),
] = False,
) -> None:
"""Send a trading operation request to the trade server.
Raises:
typer.BadParameter: If --yes is not provided.
"""
if not yes:
msg = "Pass --yes to send a live trade request."
raise typer.BadParameter(msg, param_hint="--yes")
_execute_export(
ctx,
lambda c: c.order_send_as_df(request=request),
)
def _create_cash_events_view(
conn: sqlite3.Connection,
deals_columns: set[str],
) -> bool:
"""Create the cash_events SQLite view derived from history_deals.
Args:
conn: Open SQLite connection.
deals_columns: Column names present in the history_deals table.
Returns:
True if the view was created, False if required columns are missing.
"""
if "type" not in deals_columns:
logger.warning("Skipping cash_events view: history_deals.type is missing")
return False
conn.execute("DROP VIEW IF EXISTS cash_events")
conn.execute(
"CREATE VIEW cash_events AS" # noqa: S608
f" SELECT * FROM history_deals WHERE type NOT IN {_TRADE_DEAL_TYPES_SQL}",
)
return True
def _create_positions_reconstructed_view(
conn: sqlite3.Connection,
deals_columns: set[str],
) -> bool:
"""Create the positions_reconstructed SQLite view derived from history_deals.
The view aggregates trade deals (``type IN (0, 1)``) by ``position_id`` and
excludes positions that have no closing deal (``entry IN (1, 3)``), so
still-open positions and reversal-only fragments are filtered out.
Open/close prices are volume-weighted averages over the corresponding
entry deals. Reversal deals (``DEAL_ENTRY_INOUT = 2``) are reported via
``volume_reversal`` and ``reversal_count``; they do not contribute to the
open or close volume/price weights because a single reversal deal mixes a
close of the existing direction with the open of the new direction.
Args:
conn: Open SQLite connection.
deals_columns: Column names present in the history_deals table.
Returns:
True if the view was created, False if required columns are missing.
"""
if not _POSITIONS_VIEW_REQUIRED_COLUMNS.issubset(deals_columns):
missing = ", ".join(sorted(_POSITIONS_VIEW_REQUIRED_COLUMNS - deals_columns))
logger.warning(
"Skipping positions_reconstructed view: history_deals missing columns: %s",
missing,
)
return False
conn.execute("DROP VIEW IF EXISTS positions_reconstructed")
conn.execute(
"CREATE VIEW positions_reconstructed AS" # noqa: S608
" SELECT"
" position_id,"
" symbol,"
" MIN(CASE WHEN entry = 0 THEN time END) AS open_time,"
" MAX(CASE WHEN entry IN (1, 2, 3) THEN time END) AS close_time,"
" MIN(CASE WHEN entry = 0 THEN type END) AS direction,"
" SUM(CASE WHEN entry = 0 THEN volume ELSE 0 END) AS volume_open,"
" SUM(CASE WHEN entry IN (1, 3) THEN volume ELSE 0 END) AS volume_close,"
" SUM(CASE WHEN entry = 2 THEN volume ELSE 0 END) AS volume_reversal,"
" CASE"
" WHEN SUM(CASE WHEN entry = 0 THEN volume ELSE 0 END) > 0"
" THEN SUM(CASE WHEN entry = 0 THEN price * volume ELSE 0 END)"
" / SUM(CASE WHEN entry = 0 THEN volume ELSE 0 END)"
" END AS open_price,"
" CASE"
" WHEN SUM(CASE WHEN entry IN (1, 3) THEN volume ELSE 0 END) > 0"
" THEN SUM(CASE WHEN entry IN (1, 3) THEN price * volume ELSE 0 END)"
" / SUM(CASE WHEN entry IN (1, 3) THEN volume ELSE 0 END)"
" END AS close_price,"
" SUM(profit) AS total_profit,"
" SUM(CASE WHEN entry = 2 THEN 1 ELSE 0 END) AS reversal_count,"
" COUNT(*) AS deals_count"
" FROM history_deals"
f" WHERE type IN {_TRADE_DEAL_TYPES_SQL} AND position_id != 0"
" GROUP BY position_id, symbol"
" HAVING SUM(CASE WHEN entry IN (1, 3) THEN 1 ELSE 0 END) > 0",
)
return True
def _write_frame_to_sqlite(
conn: sqlite3.Connection,
frame: pd.DataFrame,
table_name: str,
if_exists: IfExists,
) -> bool:
"""Write a non-empty-schema frame to SQLite.
Args:
conn: Open SQLite connection.
frame: DataFrame to write.
table_name: Target SQLite table name.
if_exists: Table conflict behavior.
Returns:
True if a table was written, False if the frame had no columns.
"""
if len(frame.columns) == 0:
logger.warning("Skipping %s: dataset returned no columns", table_name)
return False
frame.to_sql( # type: ignore[reportUnknownMemberType]
table_name,
conn,
if_exists=if_exists.value,
index=False,
chunksize=50_000,
method="multi",
)
return True
def _create_collect_history_indexes(
conn: sqlite3.Connection,
written_columns: dict[Dataset, set[str]],
) -> None:
"""Create useful indexes for collected history tables when present."""
if {"symbol", "time"}.issubset(written_columns.get(Dataset.rates, set())):
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_rates_symbol_time ON rates(symbol, time)",
)
if {"symbol", "time"}.issubset(written_columns.get(Dataset.ticks, set())):
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ticks_symbol_time ON ticks(symbol, time)",
)
if {"position_id", "symbol"}.issubset(
written_columns.get(Dataset.history_deals, set())
):
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_history_deals_position_symbol"
" ON history_deals(position_id, symbol)",
)
def _record_written_columns(
written_columns: dict[Dataset, set[str]],
dataset: Dataset,
frame: pd.DataFrame,
) -> None:
"""Remember columns for datasets written during streaming collection."""
columns = set(frame.columns)
if dataset in written_columns:
written_columns[dataset].update(columns)
else:
written_columns[dataset] = columns
def _write_streamed_frame(
conn: sqlite3.Connection,
frame: pd.DataFrame,
dataset: Dataset,
table_exists: bool,
if_exists: IfExists,
written_columns: dict[Dataset, set[str]],
) -> bool:
"""Write one streamed dataset frame and track table state.
Args:
conn: Open SQLite connection.
frame: DataFrame to write.
dataset: Dataset being written.
table_exists: Whether this dataset table has already been written.
if_exists: Initial table conflict behavior.
written_columns: Mutable map of columns written by dataset.
Returns:
True if the dataset table exists after this write attempt.
"""
write_mode = IfExists.APPEND if table_exists else if_exists
if _write_frame_to_sqlite(
conn,
frame,
_DATASET_TABLE_NAMES[dataset],
write_mode,
):
_record_written_columns(written_columns, dataset, frame)
return True
return table_exists
def _write_rates_dataset(
conn: sqlite3.Connection,
client: Mt5DataClient,
symbols: list[str],
timeframe: int,
date_from: datetime,
date_to: datetime,
if_exists: IfExists,
written_columns: dict[Dataset, set[str]],
) -> bool:
"""Stream rates frames into SQLite.
Args:
conn: Open SQLite connection.
client: Connected MT5 data client.
symbols: Symbols to collect.
timeframe: Rates timeframe integer.
date_from: Start date.
date_to: End date.
if_exists: Initial table conflict behavior.
written_columns: Mutable map of columns written by dataset.
Returns:
True if the rates table was written.
"""
table_exists = False
for sym in symbols:
frame = client.copy_rates_range_as_df(
symbol=sym,
timeframe=timeframe,
date_from=date_from,
date_to=date_to,
)
frame.insert(0, "symbol", sym)
frame.insert(1, "timeframe", timeframe)
table_exists = _write_streamed_frame(
conn,
frame,
Dataset.rates,
table_exists,
if_exists,
written_columns,
)
return table_exists
def _write_ticks_dataset(
conn: sqlite3.Connection,
client: Mt5DataClient,
symbols: list[str],
flags: int,
date_from: datetime,
date_to: datetime,
if_exists: IfExists,
written_columns: dict[Dataset, set[str]],
) -> bool:
"""Stream ticks frames into SQLite.
Args:
conn: Open SQLite connection.
client: Connected MT5 data client.
symbols: Symbols to collect.
flags: Tick copy flags integer.
date_from: Start date.
date_to: End date.
if_exists: Initial table conflict behavior.
written_columns: Mutable map of columns written by dataset.
Returns:
True if the ticks table was written.
"""
table_exists = False
for sym in symbols:
frame = client.copy_ticks_range_as_df(
symbol=sym,
date_from=date_from,
date_to=date_to,
flags=flags,
)
frame.insert(0, "symbol", sym)
table_exists = _write_streamed_frame(
conn,
frame,
Dataset.ticks,
table_exists,
if_exists,
written_columns,
)
return table_exists
def _write_history_dataset(
conn: sqlite3.Connection,
fetch: Callable[..., pd.DataFrame],
dataset: Dataset,
symbols: list[str],
date_from: datetime,
date_to: datetime,
if_exists: IfExists,
written_columns: dict[Dataset, set[str]],
) -> bool:
"""Stream a history dataset into SQLite with exact symbol filtering.
Args:
conn: Open SQLite connection.
fetch: Bound history_orders_get_as_df / history_deals_get_as_df method.
dataset: History dataset being written.
symbols: Symbols to collect.
date_from: Start date.
date_to: End date.
if_exists: Initial table conflict behavior.
written_columns: Mutable map of columns written by dataset.
Returns:
True if the history table was written.
"""
table_exists = False
for sym in symbols:
frame = fetch(date_from=date_from, date_to=date_to, symbol=sym)
if "symbol" in frame.columns:
frame = frame[frame["symbol"] == sym]
table_exists = _write_streamed_frame(
conn,
frame,
dataset,
table_exists,
if_exists,
written_columns,
)
return table_exists
def _write_collected_datasets(
conn: sqlite3.Connection,
client: Mt5DataClient,
symbols: list[str],
datasets: set[Dataset],
timeframe: int,
flags: int,
date_from: datetime,
date_to: datetime,
if_exists: IfExists,
) -> tuple[set[Dataset], dict[Dataset, set[str]]]:
"""Collect selected datasets and stream each symbol frame into SQLite.
Args:
conn: Open SQLite connection.
client: Connected MT5 data client.
symbols: Symbols to collect.
datasets: Selected datasets to write.
timeframe: Rates timeframe integer.
flags: Tick copy flags integer.
date_from: Start date.
date_to: End date.
if_exists: Initial table conflict behavior.
Returns:
Written datasets and their columns.
"""
written_columns: dict[Dataset, set[str]] = {}
written_tables: set[Dataset] = set()
if Dataset.rates in datasets and _write_rates_dataset(
conn,
client,
symbols,
timeframe,
date_from,
date_to,
if_exists,
written_columns,
):
written_tables.add(Dataset.rates)
if Dataset.ticks in datasets and _write_ticks_dataset(
conn,
client,
symbols,
flags,
date_from,
date_to,
if_exists,
written_columns,
):
written_tables.add(Dataset.ticks)
if Dataset.history_orders in datasets and _write_history_dataset(
conn,
client.history_orders_get_as_df,
Dataset.history_orders,
symbols,
date_from,
date_to,
if_exists,
written_columns,
):
written_tables.add(Dataset.history_orders)
if Dataset.history_deals in datasets and _write_history_dataset(
conn,
client.history_deals_get_as_df,
Dataset.history_deals,
symbols,
date_from,
date_to,
if_exists,
written_columns,
):
written_tables.add(Dataset.history_deals)
return written_tables, written_columns
@app.command()
def collect_history(
ctx: typer.Context,
symbol: Annotated[
list[str],
typer.Option(
"--symbol",
"-s",
help="Symbol to collect (repeat for multiple symbols).",
),
],
date_from: Annotated[
datetime,
typer.Option(click_type=DATETIME_TYPE, help="Start date."),
],
date_to: Annotated[
datetime,
typer.Option(click_type=DATETIME_TYPE, help="End date."),
],
dataset: Annotated[
list[Dataset] | None,
typer.Option(
"--dataset",
help=(
"Dataset to include (repeat for multiple)."
" Defaults to all: rates, ticks, history-orders, history-deals."
),
),
] = None,
timeframe: Annotated[
int,
typer.Option(
click_type=TIMEFRAME_TYPE,
help="Rates timeframe (e.g., M1, H1, D1).",
),
] = 1,
flags: Annotated[
int,
typer.Option(
click_type=TICK_FLAGS_TYPE,
help="Tick copy flags (ALL, INFO, TRADE, or integer).",
),
] = 1,
if_exists: Annotated[
IfExists,
typer.Option(
"--if-exists",
help="Behavior when a target table already exists.",
),
] = IfExists.FAIL,
with_views: Annotated[
bool,
typer.Option(
"--with-views",
help=(
"Add cash_events and positions_reconstructed SQLite views"
" derived from history_deals."
),
),
] = False,
) -> 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.
With ``--with-views`` (requires the ``history-deals`` dataset), optional
views ``cash_events`` and ``positions_reconstructed`` are derived from
``history_deals`` when the required columns are present.
Raises:
typer.BadParameter: If the output format is not SQLite3.
"""
export_ctx = _get_export_context(ctx)
if export_ctx.output_format != "sqlite3":
msg = (
"collect-history requires SQLite3 output."
" Use a .db/.sqlite/.sqlite3 extension or --format sqlite3."
)
raise typer.BadParameter(msg)
datasets = set(dataset) if dataset else set(Dataset)
client = Mt5DataClient(config=export_ctx.config)
client.initialize_and_login_mt5()
try:
with sqlite3.connect(export_ctx.output) as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
written_tables, written_columns = _write_collected_datasets(
conn,
client,
symbol,
datasets,
timeframe,
flags,
date_from,
date_to,
if_exists,
)
_create_collect_history_indexes(conn, written_columns)
if with_views and Dataset.history_deals in written_tables:
_create_cash_events_view(conn, written_columns[Dataset.history_deals])
_create_positions_reconstructed_view(
conn,
written_columns[Dataset.history_deals],
)
elif with_views:
logger.warning(
"--with-views ignored: history_deals table was not written"
)
logger.info(
"Collected %s for %d symbol(s) into %s",
", ".join(sorted(ds.value for ds in datasets)),
len(symbol),
export_ctx.output,
)
finally:
client.shutdown()
def main() -> None: def main() -> None:
"""Run the mt5cli CLI.""" """Run the mt5cli CLI."""
app() app()
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mt5cli" name = "mt5cli"
version = "0.1.0" version = "0.3.0"
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"}]
+24 -15
View File
@@ -50,21 +50,22 @@ Global options MUST precede the subcommand.
## Commands ## Commands
| Command | Required options | Optional options | | Command | Required options | Optional options |
| ---------------- | ----------------------------------------------------- | --------------------------------------------------------------------------- | | ----------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rates-from` | `--symbol`, `--timeframe`, `--date-from`, `--count` | — | | `rates-from` | `--symbol`, `--timeframe`, `--date-from`, `--count` | — |
| `rates-from-pos` | `--symbol`, `--timeframe`, `--start-pos`, `--count` | — | | `rates-from-pos` | `--symbol`, `--timeframe`, `--start-pos`, `--count` | — |
| `rates-range` | `--symbol`, `--timeframe`, `--date-from`, `--date-to` | — | | `rates-range` | `--symbol`, `--timeframe`, `--date-from`, `--date-to` | — |
| `ticks-from` | `--symbol`, `--date-from`, `--count`, `--flags` | — | | `ticks-from` | `--symbol`, `--date-from`, `--count`, `--flags` | — |
| `ticks-range` | `--symbol`, `--date-from`, `--date-to`, `--flags` | — | | `ticks-range` | `--symbol`, `--date-from`, `--date-to`, `--flags` | — |
| `account-info` | — | — | | `account-info` | — | — |
| `terminal-info` | — | — | | `terminal-info` | — | — |
| `symbols` | — | `--group` (e.g., `*USD*`) | | `symbols` | — | `--group` (e.g., `*USD*`) |
| `symbol-info` | `--symbol` | — | | `symbol-info` | `--symbol` | — |
| `orders` | — | `--symbol`, `--group`, `--ticket` | | `orders` | — | `--symbol`, `--group`, `--ticket` |
| `positions` | — | `--symbol`, `--group`, `--ticket` | | `positions` | — | `--symbol`, `--group`, `--ticket` |
| `history-orders` | — | `--date-from`, `--date-to`, `--group`, `--symbol`, `--ticket`, `--position` | | `history-orders` | — | `--date-from`, `--date-to`, `--group`, `--symbol`, `--ticket`, `--position` |
| `history-deals` | — | `--date-from`, `--date-to`, `--group`, `--symbol`, `--ticket`, `--position` | | `history-deals` | — | `--date-from`, `--date-to`, `--group`, `--symbol`, `--ticket`, `--position` |
| `collect-history` | `--symbol` (repeatable), `--date-from`, `--date-to` | `--dataset` (repeatable; rates/ticks/history-orders/history-deals; default all), `--timeframe` (M1; recorded on rates), `--flags` (ALL), `--if-exists` (append/replace/fail; default fail), `--with-views` (SQLite3 output only) |
## Examples ## Examples
@@ -85,6 +86,14 @@ mt5cli -o data.db --table symbols symbols --group "*USD*"
# Historical deals filtered by symbol (using an already-logged-in MT5 terminal). # Historical deals filtered by symbol (using an already-logged-in MT5 terminal).
mt5cli -o deals.csv history-deals --symbol EURUSD --date-from 2024-01-01 mt5cli -o deals.csv history-deals --symbol EURUSD --date-from 2024-01-01
# Bundle selected historical datasets into one SQLite db, appending to any
# existing tables, plus cash_events and positions_reconstructed views.
mt5cli -o history.db collect-history \
--symbol EURUSD --symbol GBPUSD \
--date-from 2024-01-01 --date-to 2024-02-01 \
--dataset rates --dataset history-deals \
--timeframe M1 --flags ALL --if-exists append --with-views
``` ```
## Guidelines ## Guidelines
+882 -1
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import re
import sqlite3 import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -18,6 +20,7 @@ if TYPE_CHECKING:
from mt5cli.cli import ( from mt5cli.cli import (
DATETIME_TYPE, DATETIME_TYPE,
REQUEST_TYPE,
TICK_FLAG_MAP, TICK_FLAG_MAP,
TICK_FLAGS_TYPE, TICK_FLAGS_TYPE,
TIMEFRAME_MAP, TIMEFRAME_MAP,
@@ -29,11 +32,18 @@ from mt5cli.cli import (
export_dataframe, export_dataframe,
main, main,
parse_datetime, parse_datetime,
parse_request,
parse_tick_flags, parse_tick_flags,
parse_timeframe, parse_timeframe,
) )
runner = CliRunner() runner = CliRunner()
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
def normalize_cli_output(output: str) -> str:
"""Normalize CLI output for cross-platform assertions."""
return " ".join(_ANSI_ESCAPE_RE.sub("", output).split())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -203,6 +213,43 @@ class TestParseTickFlags:
parse_tick_flags("INVALID") parse_tick_flags("INVALID")
# ---------------------------------------------------------------------------
# parse_request
# ---------------------------------------------------------------------------
class TestParseRequest:
"""Tests for parse_request."""
def test_inline_json(self) -> None:
"""Test parsing an inline JSON object string."""
result = parse_request('{"action": 1, "symbol": "EURUSD"}')
assert result == {"action": 1, "symbol": "EURUSD"}
def test_file_reference(self, tmp_path: Path) -> None:
"""Test parsing JSON from a file via the @path syntax."""
path = tmp_path / "req.json"
path.write_text('{"action": 2}', encoding="utf-8")
result = parse_request(f"@{path}")
assert result == {"action": 2}
def test_invalid_json_raises(self) -> None:
"""Test that invalid JSON raises ValueError."""
with pytest.raises(ValueError, match="Invalid JSON request"):
parse_request("not json")
def test_non_object_raises(self) -> None:
"""Test that a non-object JSON raises ValueError."""
with pytest.raises(ValueError, match="must be a JSON object"):
parse_request("[1, 2, 3]")
def test_missing_file_raises(self, tmp_path: Path) -> None:
"""Test that a missing request file raises ValueError."""
path = tmp_path / "missing.json"
with pytest.raises(ValueError, match="Failed to read JSON request file"):
parse_request(f"@{path}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -279,6 +326,19 @@ class TestTickFlagsType:
TICK_FLAGS_TYPE.convert("bad", None, None) TICK_FLAGS_TYPE.convert("bad", None, None)
class TestRequestType:
"""Tests for _RequestType."""
def test_convert_string(self) -> None:
"""Test converting a JSON string to a request dictionary."""
assert REQUEST_TYPE.convert('{"action": 1}', None, None) == {"action": 1}
def test_convert_invalid(self) -> None:
"""Test that invalid values raise BadParameter."""
with pytest.raises(Exception, match="Invalid JSON request"):
REQUEST_TYPE.convert("bad", None, None)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _execute_export # _execute_export
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -331,6 +391,12 @@ def mock_client(mocker: MockerFixture) -> MagicMock:
client.positions_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_orders_get_as_df.return_value = sample_df
client.history_deals_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.cli.Mt5DataClient", return_value=client) mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
return client return client
@@ -630,6 +696,213 @@ 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_version(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test version command."""
output = tmp_path / "out.csv"
result = runner.invoke(app, ["-o", str(output), "version"])
assert result.exit_code == 0, result.output
mock_client.version_as_df.assert_called_once()
def test_last_error(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test last-error command."""
output = tmp_path / "out.csv"
result = runner.invoke(app, ["-o", str(output), "last-error"])
assert result.exit_code == 0, result.output
mock_client.last_error_as_df.assert_called_once()
def test_symbol_info_tick(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test symbol-info-tick command."""
output = tmp_path / "out.csv"
result = runner.invoke(
app,
["-o", str(output), "symbol-info-tick", "--symbol", "EURUSD"],
)
assert result.exit_code == 0, result.output
mock_client.symbol_info_tick_as_df.assert_called_once_with(
symbol="EURUSD",
)
def test_market_book(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test market-book command."""
output = tmp_path / "out.csv"
result = runner.invoke(
app,
["-o", str(output), "market-book", "--symbol", "EURUSD"],
)
assert result.exit_code == 0, result.output
mock_client.market_book_get_as_df.assert_called_once_with(
symbol="EURUSD",
)
def test_order_check(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test order-check command with inline JSON."""
output = tmp_path / "out.csv"
request = json.dumps({"action": 1, "symbol": "EURUSD", "volume": 0.1})
result = runner.invoke(
app,
["-o", str(output), "order-check", "--request", request],
)
assert result.exit_code == 0, result.output
mock_client.order_check_as_df.assert_called_once_with(
request={"action": 1, "symbol": "EURUSD", "volume": 0.1},
)
def test_order_check_file_reference(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test order-check command with file-based JSON."""
output = tmp_path / "out.csv"
req_path = tmp_path / "req.json"
req_path.write_text(
json.dumps({"action": 2, "symbol": "EURUSD"}),
encoding="utf-8",
)
result = runner.invoke(
app,
["-o", str(output), "order-check", "--request", f"@{req_path}"],
)
assert result.exit_code == 0, result.output
mock_client.order_check_as_df.assert_called_once_with(
request={"action": 2, "symbol": "EURUSD"},
)
def test_order_check_invalid_request(
self,
tmp_path: Path,
mock_client: MagicMock, # noqa: ARG002
) -> None:
"""Test order-check rejects invalid JSON."""
output = tmp_path / "out.csv"
result = runner.invoke(
app,
["-o", str(output), "order-check", "--request", "not-json"],
)
assert result.exit_code != 0
assert "Invalid JSON request" in normalize_cli_output(result.output)
def test_order_check_missing_request_file(
self,
tmp_path: Path,
mock_client: MagicMock, # noqa: ARG002
) -> None:
"""Test order-check rejects a missing request file."""
output = tmp_path / "out.csv"
missing = tmp_path / "missing.json"
result = runner.invoke(
app,
["-o", str(output), "order-check", "--request", f"@{missing}"],
)
assert result.exit_code != 0
assert "Failed to read JSON request file" in normalize_cli_output(
result.output,
)
def test_order_send(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test order-send command with file-based JSON."""
output = tmp_path / "out.csv"
req_path = tmp_path / "req.json"
req_path.write_text(
json.dumps({"action": 2, "symbol": "EURUSD"}),
encoding="utf-8",
)
result = runner.invoke(
app,
[
"-o",
str(output),
"order-send",
"--request",
f"@{req_path}",
"--yes",
],
)
assert result.exit_code == 0, result.output
mock_client.order_send_as_df.assert_called_once_with(
request={"action": 2, "symbol": "EURUSD"},
)
def test_order_send_inline_json(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test order-send command with inline JSON."""
output = tmp_path / "out.csv"
request = json.dumps({"action": 1, "symbol": "EURUSD", "volume": 0.1})
result = runner.invoke(
app,
[
"-o",
str(output),
"order-send",
"--request",
request,
"--yes",
],
)
assert result.exit_code == 0, result.output
mock_client.order_send_as_df.assert_called_once_with(
request={"action": 1, "symbol": "EURUSD", "volume": 0.1},
)
def test_order_send_requires_yes(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test order-send requires explicit confirmation."""
output = tmp_path / "out.csv"
request = json.dumps({"action": 1, "symbol": "EURUSD"})
result = runner.invoke(
app,
["-o", str(output), "order-send", "--request", request],
)
assert result.exit_code != 0
assert "Pass --yes to send a live trade request" in normalize_cli_output(
result.output,
)
mock_client.order_send_as_df.assert_not_called()
def test_order_send_invalid_request(
self,
tmp_path: Path,
mock_client: MagicMock, # noqa: ARG002
) -> None:
"""Test order-send rejects invalid JSON."""
output = tmp_path / "out.csv"
result = runner.invoke(
app,
["-o", str(output), "order-send", "--request", "[1,2]", "--yes"],
)
assert result.exit_code != 0
assert "must be a JSON object" in normalize_cli_output(result.output)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Callback / shared options # Callback / shared options
@@ -647,7 +920,7 @@ class TestCallback:
["-o", str(output), "account-info"], ["-o", str(output), "account-info"],
) )
assert result.exit_code != 0 assert result.exit_code != 0
assert "Cannot detect format" in result.output assert "Cannot detect format" in normalize_cli_output(result.output)
def test_connection_args_forwarded( def test_connection_args_forwarded(
self, self,
@@ -741,6 +1014,614 @@ class TestCallback:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_DEALS_FIXTURE: dict[str, list[object]] = {
"ticket": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
"position_id": [100, 100, 100, 0, 200, 200, 300, 400, 400, 500, 500, 600, 600, 600],
"symbol": [
"EURUSD",
"EURUSD",
"EURUSD",
"",
"EURUSD",
"EURUSD",
"GBPUSD",
"GBPUSD",
"GBPUSD",
"EURUSD",
"EURUSD",
"GBPUSD",
"GBPUSD",
"GBPUSD",
],
"time": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
# type: 0=BUY, 1=SELL, 2=BALANCE
"type": [0, 0, 1, 2, 0, 1, 0, 0, 2, 0, 1, 0, 1, 1],
# entry: 0=IN, 1=OUT, 2=INOUT (reversal), 3=OUT_BY
"entry": [0, 0, 1, 0, 0, 1, 0, 0, 2, 0, 3, 0, 2, 1],
"volume": [1.0, 3.0, 4.0, 0.0, 2.0, 2.0, 5.0, 1.0, 1.0, 2.0, 2.0, 3.0, 1.0, 3.0],
"price": [
1.10,
1.20,
1.50,
0.0,
2.00,
2.20,
1.30,
1.30,
1.40,
1.00,
1.05,
1.10,
9.99,
1.40,
],
"profit": [0.0, 0.0, 10.0, 5.0, 0.0, 8.0, 0.0, 0.0, -1.0, 0.0, 3.0, 0.0, -2.0, 7.0],
}
def _build_history_client(mocker: MockerFixture) -> MagicMock:
"""Build a mocked Mt5DataClient with per-symbol history results."""
client = MagicMock()
def _rates(**kwargs: object) -> pd.DataFrame:
return pd.DataFrame({
"time": [1],
"open": [1.0],
"symbol_arg": [kwargs.get("symbol")],
})
def _ticks(**kwargs: object) -> pd.DataFrame:
return pd.DataFrame({
"time": [1],
"bid": [1.0],
"symbol_arg": [kwargs.get("symbol")],
})
client.copy_rates_range_as_df.side_effect = _rates
client.copy_ticks_range_as_df.side_effect = _ticks
def _orders(**kwargs: object) -> pd.DataFrame:
return pd.DataFrame({"ticket": [10], "symbol": [kwargs.get("symbol")]})
def _deals(**kwargs: object) -> pd.DataFrame:
sym = kwargs.get("symbol")
df = pd.DataFrame(_DEALS_FIXTURE)
return df[df["symbol"] == sym].reset_index(drop=True)
client.history_orders_get_as_df.side_effect = _orders
client.history_deals_get_as_df.side_effect = _deals
mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
return client
class TestCollectHistory:
"""Tests for the collect-history command."""
@pytest.fixture
def history_client(self, mocker: MockerFixture) -> MagicMock:
"""Create a mocked Mt5DataClient with history-style DataFrames."""
return _build_history_client(mocker)
def test_collect_history_writes_all_tables(
self,
tmp_path: Path,
history_client: MagicMock,
) -> None:
"""Test that collect-history writes rates, ticks, and history tables."""
output = tmp_path / "history.db"
result = runner.invoke(
app,
[
"-o",
str(output),
"collect-history",
"--symbol",
"EURUSD",
"--symbol",
"GBPUSD",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
],
)
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(
symbol="EURUSD",
date_from=datetime(2024, 1, 1, tzinfo=UTC),
date_to=datetime(2024, 2, 1, tzinfo=UTC),
flags=1,
)
with sqlite3.connect(output) as conn:
tables = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'",
).fetchall()
}
assert {"rates", "ticks", "history_orders", "history_deals"} <= tables
def test_collect_history_history_fetched_per_symbol(
self,
tmp_path: Path,
history_client: MagicMock,
) -> None:
"""Test that history-orders and history-deals are fetched per symbol."""
output = tmp_path / "history.db"
result = runner.invoke(
app,
[
"-o",
str(output),
"collect-history",
"--symbol",
"EURUSD",
"--symbol",
"GBPUSD",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
],
)
assert result.exit_code == 0, result.output
assert history_client.history_orders_get_as_df.call_count == 2
assert history_client.history_deals_get_as_df.call_count == 2
history_client.history_orders_get_as_df.assert_any_call(
date_from=datetime(2024, 1, 1, tzinfo=UTC),
date_to=datetime(2024, 2, 1, tzinfo=UTC),
symbol="EURUSD",
)
history_client.history_deals_get_as_df.assert_any_call(
date_from=datetime(2024, 1, 1, tzinfo=UTC),
date_to=datetime(2024, 2, 1, tzinfo=UTC),
symbol="GBPUSD",
)
@pytest.mark.parametrize(
("selected", "expected_tables", "excluded_calls"),
[
(
["rates", "history-deals"],
{"rates", "history_deals"},
("copy_ticks_range_as_df", "history_orders_get_as_df"),
),
(
["ticks", "history-orders"],
{"ticks", "history_orders"},
("copy_rates_range_as_df", "history_deals_get_as_df"),
),
],
)
def test_collect_history_dataset_selection(
self,
tmp_path: Path,
history_client: MagicMock,
selected: list[str],
expected_tables: set[str],
excluded_calls: tuple[str, ...],
) -> None:
"""Test that --dataset limits which datasets are fetched and written."""
output = tmp_path / "history.db"
args = [
"-o",
str(output),
"collect-history",
"--symbol",
"EURUSD",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
]
for name in selected:
args.extend(["--dataset", name])
result = runner.invoke(app, args)
assert result.exit_code == 0, result.output
for name in excluded_calls:
getattr(history_client, name).assert_not_called()
with sqlite3.connect(output) as conn:
tables = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'",
).fetchall()
}
assert expected_tables <= tables
assert tables.isdisjoint(
{"rates", "ticks", "history_orders", "history_deals"} - expected_tables
)
def test_collect_history_rates_table_has_timeframe(
self,
tmp_path: Path,
history_client: MagicMock, # noqa: ARG002
) -> None:
"""Test that the rates table carries the requested timeframe value."""
output = tmp_path / "history.db"
result = runner.invoke(
app,
[
"-o",
str(output),
"collect-history",
"--symbol",
"EURUSD",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
"--timeframe",
"H1",
"--dataset",
"rates",
],
)
assert result.exit_code == 0, result.output
with sqlite3.connect(output) as conn:
rows = conn.execute(
"SELECT DISTINCT timeframe FROM rates",
).fetchall()
assert rows == [(16385,)]
def test_collect_history_if_exists_append(
self,
tmp_path: Path,
history_client: MagicMock, # noqa: ARG002
) -> None:
"""Test that --if-exists=append accumulates rows across runs."""
output = tmp_path / "history.db"
common = [
"-o",
str(output),
"collect-history",
"--symbol",
"EURUSD",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
"--dataset",
"rates",
]
first = runner.invoke(app, common)
second = runner.invoke(app, [*common, "--if-exists", "append"])
assert first.exit_code == 0, first.output
assert second.exit_code == 0, second.output
with sqlite3.connect(output) as conn:
(count,) = conn.execute("SELECT COUNT(*) FROM rates").fetchone()
assert count == 2
def test_collect_history_if_exists_fail(
self,
tmp_path: Path,
history_client: MagicMock, # noqa: ARG002
) -> None:
"""Test that --if-exists=fail rejects writing into an existing table."""
output = tmp_path / "history.db"
common = [
"-o",
str(output),
"collect-history",
"--symbol",
"EURUSD",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
"--dataset",
"rates",
]
first = runner.invoke(app, common)
second = runner.invoke(app, [*common, "--if-exists", "fail"])
assert first.exit_code == 0, first.output
assert second.exit_code != 0
def test_collect_history_ticks_default_flags_all(
self,
tmp_path: Path,
history_client: MagicMock,
) -> None:
"""Test that --flags defaults to ALL for ticks."""
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",
],
)
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),
flags=1,
)
def test_collect_history_with_views(
self,
tmp_path: Path,
history_client: MagicMock, # noqa: ARG002
) -> None:
"""Test that --with-views creates cash_events and positions views."""
output = tmp_path / "history.db"
result = runner.invoke(
app,
[
"-o",
str(output),
"collect-history",
"--symbol",
"EURUSD",
"--symbol",
"GBPUSD",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
"--with-views",
],
)
assert result.exit_code == 0, result.output
with sqlite3.connect(output) as conn:
views = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='view'",
).fetchall()
}
cash = conn.execute("SELECT type FROM cash_events").fetchall()
positions = {
row[0]: row
for row in conn.execute(
"SELECT position_id, volume_open, volume_close,"
" volume_reversal, open_price, close_price, reversal_count"
" FROM positions_reconstructed",
).fetchall()
}
assert {"cash_events", "positions_reconstructed"} <= views
assert all(row[0] not in {0, 1} for row in cash)
# Position 100 (BUY 1@1.10 + BUY 3@1.20 then SELL 4@1.50) is closed.
# Position 200 (BUY 2@2.00 then SELL 2@2.20) is closed.
# Position 300 (open-only) and 400 (reversal-only) are excluded.
assert set(positions) == {100, 200, 500, 600}
pos_100 = positions[100]
tol = 1e-9
assert abs(pos_100[1] - 4.0) < tol # volume_open
assert abs(pos_100[2] - 4.0) < tol # volume_close
assert abs(pos_100[3] - 0.0) < tol # volume_reversal
# Volume-weighted open: (1*1.10 + 3*1.20) / 4 = 1.175
assert abs(pos_100[4] - 1.175) < tol
# Volume-weighted close: (4*1.50) / 4 = 1.50
assert abs(pos_100[5] - 1.50) < tol
assert pos_100[6] == 0 # reversal_count
pos_500 = positions[500]
assert abs(pos_500[2] - 2.0) < tol # OUT_BY contributes to close volume
assert abs(pos_500[5] - 1.05) < tol
pos_600 = positions[600]
assert abs(pos_600[1] - 3.0) < tol
assert abs(pos_600[2] - 3.0) < tol
assert abs(pos_600[3] - 1.0) < tol
assert abs(pos_600[4] - 1.10) < tol
assert abs(pos_600[5] - 1.40) < tol
assert pos_600[6] == 1
def test_collect_history_filters_history_symbols_exactly(
self,
tmp_path: Path,
mocker: MockerFixture,
) -> None:
"""Test that history wildcard results are filtered to exact symbols."""
client = MagicMock()
client.history_orders_get_as_df.return_value = pd.DataFrame({
"ticket": [1, 2],
"symbol": ["EURUSD", "EURUSDm"],
})
client.history_deals_get_as_df.return_value = pd.DataFrame({
"ticket": [3, 4],
"symbol": ["EURUSD", "EURUSDm"],
})
mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
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",
"history-orders",
"--dataset",
"history-deals",
],
)
assert result.exit_code == 0, result.output
with sqlite3.connect(output) as conn:
order_symbols = conn.execute(
"SELECT DISTINCT symbol FROM history_orders",
).fetchall()
deal_symbols = conn.execute(
"SELECT DISTINCT symbol FROM history_deals",
).fetchall()
assert order_symbols == [("EURUSD",)]
assert deal_symbols == [("EURUSD",)]
def test_collect_history_requires_sqlite_format(
self,
tmp_path: Path,
history_client: MagicMock, # noqa: ARG002
) -> None:
"""Test that non-SQLite output is rejected."""
output = tmp_path / "history.csv"
result = runner.invoke(
app,
[
"-o",
str(output),
"collect-history",
"--symbol",
"EURUSD",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
],
)
assert result.exit_code != 0
assert "requires SQLite3" in normalize_cli_output(result.output)
def test_collect_history_requires_symbol(
self,
tmp_path: Path,
history_client: MagicMock, # noqa: ARG002
) -> None:
"""Test that at least one --symbol is required."""
output = tmp_path / "history.db"
result = runner.invoke(
app,
[
"-o",
str(output),
"collect-history",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
],
)
assert result.exit_code != 0
def test_collect_history_views_skipped_when_columns_missing(
self,
tmp_path: Path,
mocker: MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that views are not created when required columns are missing."""
client = MagicMock()
client.copy_rates_range_as_df.return_value = pd.DataFrame({"x": [1]})
client.copy_ticks_range_as_df.return_value = pd.DataFrame({"x": [1]})
client.history_orders_get_as_df.return_value = pd.DataFrame({"x": [1]})
client.history_deals_get_as_df.return_value = pd.DataFrame({"x": [1]})
mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
output = tmp_path / "history.db"
with caplog.at_level(logging.WARNING, logger="mt5cli.cli"):
result = runner.invoke(
app,
[
"-o",
str(output),
"collect-history",
"--symbol",
"EURUSD",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
"--with-views",
],
)
assert result.exit_code == 0, result.output
with sqlite3.connect(output) as conn:
views = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='view'",
).fetchall()
}
assert "cash_events" not in views
assert "positions_reconstructed" not in views
assert "Skipping cash_events view" in caplog.text
assert "Skipping positions_reconstructed view" in caplog.text
def test_collect_history_skips_empty_history_without_columns(
self,
tmp_path: Path,
mocker: MockerFixture,
) -> None:
"""Test that empty no-column history results do not fail collection."""
client = MagicMock()
client.copy_rates_range_as_df.return_value = pd.DataFrame({"time": [1]})
client.history_deals_get_as_df.return_value = pd.DataFrame()
mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
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",
"rates",
"--dataset",
"history-deals",
],
)
assert result.exit_code == 0, result.output
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" in tables
assert "history_deals" not in tables
def test_collect_history_warns_when_views_requested_without_deals(
self,
tmp_path: Path,
history_client: MagicMock, # noqa: ARG002
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that --with-views warns when history_deals is not written."""
output = tmp_path / "history.db"
with caplog.at_level(logging.WARNING, logger="mt5cli.cli"):
result = runner.invoke(
app,
[
"-o",
str(output),
"collect-history",
"--symbol",
"EURUSD",
"--date-from",
"2024-01-01",
"--date-to",
"2024-02-01",
"--dataset",
"rates",
"--with-views",
],
)
assert result.exit_code == 0, result.output
assert (
"--with-views ignored: history_deals table was not written" in caplog.text
)
class TestMain: class TestMain:
"""Tests for the main entry point.""" """Tests for the main entry point."""
Generated
+10 -10
View File
@@ -234,11 +234,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.13" version = "3.15"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
] ]
[[package]] [[package]]
@@ -487,7 +487,7 @@ wheels = [
[[package]] [[package]]
name = "mt5cli" name = "mt5cli"
version = "0.1.0" version = "0.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -845,15 +845,15 @@ wheels = [
[[package]] [[package]]
name = "pymdown-extensions" name = "pymdown-extensions"
version = "10.21.2" version = "10.21.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown" }, { name = "markdown" },
{ name = "pyyaml" }, { name = "pyyaml" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" },
] ]
[[package]] [[package]]
@@ -1126,11 +1126,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.3" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]] [[package]]