Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da74c11087 | |||
| c45efb953c | |||
| c1eea3fa3d | |||
| 62e5f438f0 | |||
| 50f62bca73 | |||
| 0b7dfdc621 | |||
| bab776e700 | |||
| 9bf9a6a72c | |||
| d7594ddc43 | |||
| b8ce76c9d6 | |||
| 5750625e14 | |||
| 2cf4d1cf6b |
@@ -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
|
||||
|
||||
1. Execute `scripts/qa.sh` and capture the results.
|
||||
2. Report successes, failures, warnings, and any modified files.
|
||||
|
||||
## If tools are missing
|
||||
|
||||
Install them following this priority order:
|
||||
|
||||
1. Project package managers (`uv`, `poetry`, npm scripts)
|
||||
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.
|
||||
- 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).
|
||||
- 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`.
|
||||
- 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).
|
||||
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.).
|
||||
- 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.
|
||||
- Do not run unrelated commands; only run commands needed for QA and missing-tool installation.
|
||||
|
||||
@@ -13,7 +13,14 @@ uv run pytest
|
||||
npx -y prettier --write './**/*.md'
|
||||
|
||||
# GitHub Actions
|
||||
zizmor --fix=safe .github/workflows
|
||||
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=.
|
||||
case "${OSTYPE}" in
|
||||
darwin* | linux* )
|
||||
zizmor --fix=safe .github/workflows
|
||||
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
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|| (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test')
|
||||
permissions:
|
||||
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:
|
||||
package-path: .
|
||||
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]
|
||||
with:
|
||||
package-path: .
|
||||
mkdocs-theme: material
|
||||
runs-on: ubuntu-slim
|
||||
secrets:
|
||||
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:
|
||||
if: >
|
||||
github.event_name == 'pull_request' && github.actor == 'dependabot[bot]'
|
||||
|
||||
@@ -9,84 +9,34 @@ defaults:
|
||||
shell: bash -euo pipefail {0}
|
||||
working-directory: .
|
||||
jobs:
|
||||
build:
|
||||
build-and-release:
|
||||
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
|
||||
create-github-release: true
|
||||
publish-to-pypi: false
|
||||
secrets:
|
||||
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:
|
||||
name: Publish the Python 🐍 distribution 📦 to PyPI
|
||||
if: >
|
||||
startsWith(github.ref, 'refs/tags/')
|
||||
needs:
|
||||
- build
|
||||
- github-release
|
||||
- build-and-release
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
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:
|
||||
id-token: write # IMPORTANT: mandatory for trusted publishing
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ needs.build.outputs.distribution-artifact-name }}
|
||||
name: ${{ needs.build-and-release.outputs.distribution-artifact-name }}
|
||||
path: dist/
|
||||
- name: Publish distribution 📦 to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
|
||||
@@ -50,21 +50,44 @@ python -m mt5cli -o account.csv account-info
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | ---------------------------------- |
|
||||
| `rates-from` | Export rates from a start date |
|
||||
| `rates-from-pos` | Export rates from a start position |
|
||||
| `rates-range` | Export rates for a date range |
|
||||
| `ticks-from` | Export ticks from a start date |
|
||||
| `ticks-range` | Export ticks for a date range |
|
||||
| `account-info` | Export account information |
|
||||
| `terminal-info` | Export terminal information |
|
||||
| `symbols` | Export symbol list |
|
||||
| `symbol-info` | Export symbol details |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| Command | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `rates-from` | Export rates from a start date |
|
||||
| `rates-from-pos` | Export rates from a start position |
|
||||
| `rates-range` | Export rates for a date range |
|
||||
| `ticks-from` | Export ticks from a start date |
|
||||
| `ticks-range` | Export ticks for a date range |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
|
||||
+49
-12
@@ -61,21 +61,58 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
||||
|
||||
### Information
|
||||
|
||||
| Command | Description |
|
||||
| --------------- | --------------------------- |
|
||||
| `account-info` | Export account information |
|
||||
| `terminal-info` | Export terminal information |
|
||||
| `symbols` | Export symbol list |
|
||||
| `symbol-info` | Export symbol details |
|
||||
| Command | Description |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| `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 |
|
||||
| `market-book` | Export market depth (order book) |
|
||||
|
||||
### Trading
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | ------------------------ |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| Command | Description |
|
||||
| ---------------- | ----------------------------------------------------------- |
|
||||
| `orders` | Export active orders |
|
||||
| `positions` | Export open positions |
|
||||
| `history-orders` | Export historical orders |
|
||||
| `history-deals` | Export historical deals |
|
||||
| `order-check` | Check funds sufficiency for a trade request |
|
||||
| `order-send` | Send a trade request to the trade server (`--yes` required) |
|
||||
|
||||
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
|
||||
|
||||
|
||||
+702
-2
@@ -2,13 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path # noqa: TC003
|
||||
from typing import TYPE_CHECKING, Annotated, cast
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated, Any, TypeGuard, cast
|
||||
|
||||
import click
|
||||
import typer
|
||||
@@ -55,6 +56,19 @@ TICK_FLAG_MAP: dict[str, int] = {
|
||||
"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] = {
|
||||
".csv": "csv",
|
||||
".json": "json",
|
||||
@@ -88,6 +102,31 @@ class LogLevel(StrEnum):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -180,9 +219,37 @@ class _TickFlagsType(click.ParamType):
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
class _RequestType(click.ParamType):
|
||||
"""Click parameter type for JSON order requests."""
|
||||
|
||||
name = "REQUEST"
|
||||
|
||||
def convert(
|
||||
self,
|
||||
value: object,
|
||||
param: click.Parameter | None,
|
||||
ctx: click.Context | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert a raw CLI value to an order request dictionary.
|
||||
|
||||
Args:
|
||||
value: Raw value from the command line.
|
||||
param: Click parameter instance.
|
||||
ctx: Click context.
|
||||
|
||||
Returns:
|
||||
Parsed request dictionary.
|
||||
"""
|
||||
try:
|
||||
return parse_request(str(value))
|
||||
except ValueError as exc:
|
||||
self.fail(str(exc), param, ctx)
|
||||
|
||||
|
||||
DATETIME_TYPE = _DateTimeType()
|
||||
TIMEFRAME_TYPE = _TimeframeType()
|
||||
TICK_FLAGS_TYPE = _TickFlagsType()
|
||||
REQUEST_TYPE = _RequestType()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export context
|
||||
@@ -342,6 +409,43 @@ def parse_tick_flags(value: str) -> int:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -351,6 +455,10 @@ app = typer.Typer(
|
||||
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:
|
||||
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:
|
||||
"""Run the mt5cli CLI."""
|
||||
app()
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
description = "Command-line tool for MetaTrader 5"
|
||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
|
||||
+24
-15
@@ -50,21 +50,22 @@ Global options MUST precede the subcommand.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Required options | Optional options |
|
||||
| ---------------- | ----------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `rates-from` | `--symbol`, `--timeframe`, `--date-from`, `--count` | — |
|
||||
| `rates-from-pos` | `--symbol`, `--timeframe`, `--start-pos`, `--count` | — |
|
||||
| `rates-range` | `--symbol`, `--timeframe`, `--date-from`, `--date-to` | — |
|
||||
| `ticks-from` | `--symbol`, `--date-from`, `--count`, `--flags` | — |
|
||||
| `ticks-range` | `--symbol`, `--date-from`, `--date-to`, `--flags` | — |
|
||||
| `account-info` | — | — |
|
||||
| `terminal-info` | — | — |
|
||||
| `symbols` | — | `--group` (e.g., `*USD*`) |
|
||||
| `symbol-info` | `--symbol` | — |
|
||||
| `orders` | — | `--symbol`, `--group`, `--ticket` |
|
||||
| `positions` | — | `--symbol`, `--group`, `--ticket` |
|
||||
| `history-orders` | — | `--date-from`, `--date-to`, `--group`, `--symbol`, `--ticket`, `--position` |
|
||||
| `history-deals` | — | `--date-from`, `--date-to`, `--group`, `--symbol`, `--ticket`, `--position` |
|
||||
| Command | Required options | Optional options |
|
||||
| ----------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `rates-from` | `--symbol`, `--timeframe`, `--date-from`, `--count` | — |
|
||||
| `rates-from-pos` | `--symbol`, `--timeframe`, `--start-pos`, `--count` | — |
|
||||
| `rates-range` | `--symbol`, `--timeframe`, `--date-from`, `--date-to` | — |
|
||||
| `ticks-from` | `--symbol`, `--date-from`, `--count`, `--flags` | — |
|
||||
| `ticks-range` | `--symbol`, `--date-from`, `--date-to`, `--flags` | — |
|
||||
| `account-info` | — | — |
|
||||
| `terminal-info` | — | — |
|
||||
| `symbols` | — | `--group` (e.g., `*USD*`) |
|
||||
| `symbol-info` | `--symbol` | — |
|
||||
| `orders` | — | `--symbol`, `--group`, `--ticket` |
|
||||
| `positions` | — | `--symbol`, `--group`, `--ticket` |
|
||||
| `history-orders` | — | `--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
|
||||
|
||||
@@ -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).
|
||||
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
|
||||
|
||||
+882
-1
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -18,6 +20,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from mt5cli.cli import (
|
||||
DATETIME_TYPE,
|
||||
REQUEST_TYPE,
|
||||
TICK_FLAG_MAP,
|
||||
TICK_FLAGS_TYPE,
|
||||
TIMEFRAME_MAP,
|
||||
@@ -29,11 +32,18 @@ from mt5cli.cli import (
|
||||
export_dataframe,
|
||||
main,
|
||||
parse_datetime,
|
||||
parse_request,
|
||||
parse_tick_flags,
|
||||
parse_timeframe,
|
||||
)
|
||||
|
||||
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_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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -279,6 +326,19 @@ class TestTickFlagsType:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -331,6 +391,12 @@ def mock_client(mocker: MockerFixture) -> MagicMock:
|
||||
client.positions_get_as_df.return_value = sample_df
|
||||
client.history_orders_get_as_df.return_value = sample_df
|
||||
client.history_deals_get_as_df.return_value = sample_df
|
||||
client.version_as_df.return_value = sample_df
|
||||
client.last_error_as_df.return_value = sample_df
|
||||
client.symbol_info_tick_as_df.return_value = sample_df
|
||||
client.market_book_get_as_df.return_value = sample_df
|
||||
client.order_check_as_df.return_value = sample_df
|
||||
client.order_send_as_df.return_value = sample_df
|
||||
mocker.patch("mt5cli.cli.Mt5DataClient", return_value=client)
|
||||
return client
|
||||
|
||||
@@ -630,6 +696,213 @@ class TestCommands:
|
||||
assert result.exit_code == 0, result.output
|
||||
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
|
||||
@@ -647,7 +920,7 @@ class TestCallback:
|
||||
["-o", str(output), "account-info"],
|
||||
)
|
||||
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(
|
||||
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:
|
||||
"""Tests for the main entry point."""
|
||||
|
||||
|
||||
@@ -234,11 +234,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.13"
|
||||
version = "3.15"
|
||||
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 = [
|
||||
{ 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]]
|
||||
@@ -487,7 +487,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mt5cli"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -845,15 +845,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pymdown-extensions"
|
||||
version = "10.21.2"
|
||||
version = "10.21.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -1126,11 +1126,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
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 = [
|
||||
{ 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]]
|
||||
|
||||
Reference in New Issue
Block a user