mt5cli 1.2.2__tar.gz → 1.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/workflows/ci.yml +15 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/AGENTS.md +1 -1
- {mt5cli-1.2.2 → mt5cli-1.2.4}/PKG-INFO +2 -3
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/trading.md +14 -9
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/cli.py +52 -144
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/client.py +40 -8
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/history.py +1 -5
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/trading.py +53 -10
- {mt5cli-1.2.2 → mt5cli-1.2.4}/pyproject.toml +66 -65
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/conftest.py +2 -1
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_cli.py +20 -85
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_client.py +4 -10
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_contracts.py +6 -3
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_history.py +2 -1
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_telemetry.py +4 -1
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_trading.py +104 -6
- {mt5cli-1.2.2 → mt5cli-1.2.4}/uv.lock +9 -11
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.claude/agents/codex.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.claude/settings.json +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/FUNDING.yml +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/dependabot.yml +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/renovate.json +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/workflows/claude.yml +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/workflows/release.yml +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/.gitignore +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/CLAUDE.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/LICENSE +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/README.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/cli.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/client.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/converters.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/exceptions.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/grafana.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/history.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/index.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/marketdata.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/observability.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/public-contract.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/schemas.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/telemetry.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/utils.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/index.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/README.md +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/compose.yml +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/dashboards/mt5cli-market.json +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mkdocs.yml +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/__init__.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/__main__.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/contract.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/converters.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/exceptions.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/grafana.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/marketdata.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/observability.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/retry.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/schemas.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/telemetry.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/utils.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/__init__.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_examples.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_grafana.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_marketdata.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_observability.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_packaging.py +0 -0
- {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_utils.py +0 -0
|
@@ -50,6 +50,21 @@ jobs:
|
|
|
50
50
|
with:
|
|
51
51
|
package-path: .
|
|
52
52
|
runs-on: windows-latest
|
|
53
|
+
minimum-dependency-smoke-test:
|
|
54
|
+
if: >
|
|
55
|
+
github.event_name == 'push'
|
|
56
|
+
|| github.event_name == 'pull_request'
|
|
57
|
+
|| (github.event_name == 'workflow_dispatch' && inputs.workflow == 'lint-and-test')
|
|
58
|
+
permissions:
|
|
59
|
+
contents: read
|
|
60
|
+
runs-on: windows-latest
|
|
61
|
+
steps:
|
|
62
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6.0.2
|
|
63
|
+
with:
|
|
64
|
+
persist-credentials: false
|
|
65
|
+
- uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1
|
|
66
|
+
- name: Check CLI help with minimum Click and Typer versions
|
|
67
|
+
run: uv run --with click==8.4.0 --with typer==0.26.0 mt5cli --help
|
|
53
68
|
python-docs-deploy:
|
|
54
69
|
if: >
|
|
55
70
|
github.event_name == 'push'
|
|
@@ -26,7 +26,7 @@ Apply KISS, DRY, and YAGNI when changing code. Prefer the simplest implementatio
|
|
|
26
26
|
|
|
27
27
|
## Testing Guidelines
|
|
28
28
|
|
|
29
|
-
Tests use pytest, pytest-mock, doctests, and pytest-cov. Test files should match `tests/test_*.py`, classes `Test*`, and functions `test_*`. Coverage is configured with `fail_under = 100`, so add focused tests for every behavior change. Mock MT5/pdmt5 boundaries; do not require a live MetaTrader terminal in unit tests.
|
|
29
|
+
Tests use pytest, pytest-mock, doctests, and pytest-cov. Test files should match `tests/test_*.py`, classes `Test*`, and functions `test_*`. Parametrize unit tests with `pytest.mark.parametrize` when the same behavior needs coverage across multiple inputs. Coverage is configured with `fail_under = 100`, so add focused tests for every behavior change. Mock MT5/pdmt5 boundaries; do not require a live MetaTrader terminal in unit tests.
|
|
30
30
|
|
|
31
31
|
## Commit & Pull Request Guidelines
|
|
32
32
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt5cli
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.4
|
|
4
4
|
Summary: Generic MT5 data and execution infrastructure for Python applications
|
|
5
5
|
Project-URL: Repository, https://github.com/dceoy/mt5cli.git
|
|
6
6
|
Author-email: dceoy <dceoy@users.noreply.github.com>
|
|
@@ -16,10 +16,9 @@ Classifier: Programming Language :: Python
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
17
|
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
18
18
|
Requires-Python: <3.14,>=3.11
|
|
19
|
-
Requires-Dist: click>=8.1.0
|
|
20
19
|
Requires-Dist: pandas>=2.2.2
|
|
21
20
|
Requires-Dist: pdmt5>=1.2.0
|
|
22
|
-
Requires-Dist: typer>=0.
|
|
21
|
+
Requires-Dist: typer>=0.26.0
|
|
23
22
|
Provides-Extra: otel
|
|
24
23
|
Requires-Dist: opentelemetry-api; extra == 'otel'
|
|
25
24
|
Requires-Dist: opentelemetry-exporter-otlp-proto-http; extra == 'otel'
|
|
@@ -202,19 +202,24 @@ or `None` result from the underlying client is normalized to an empty DataFrame.
|
|
|
202
202
|
Downstream packages own all strategy-specific transformations. mt5cli does not
|
|
203
203
|
provide entry-deal classification, Kelly sizing, or any betting-specific helpers.
|
|
204
204
|
|
|
205
|
-
##
|
|
205
|
+
## Timestamp sources and broker clock offsets
|
|
206
206
|
|
|
207
|
-
MT5
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
The MT5 Python documentation describes tick and bar data copied by the
|
|
208
|
+
`copy_ticks_*` and `copy_rates_*` functions as UTC. It does not document a
|
|
209
|
+
different timezone contract for `symbol_info_tick()`, nor should deal epochs
|
|
210
|
+
be assumed to use a broker wall clock without source-specific evidence.
|
|
211
|
+
|
|
212
|
+
`get_tick_snapshot()` preserves the numeric MT5 epoch value in `time`; it does
|
|
213
|
+
not expose pdmt5's timezone-naive `Timestamp` conversion or alter the instant.
|
|
214
|
+
Any broker-specific offset handling therefore requires evidence from the live
|
|
215
|
+
data source in use and must be applied separately. Do not subtract a presumed
|
|
216
|
+
UTC+2 or UTC+3 offset from timestamps that are already UTC.
|
|
212
217
|
|
|
213
218
|
`estimate_server_clock_offset_seconds()` reads the latest tick for a symbol
|
|
214
219
|
and returns the broker's clock offset from true UTC, rounded to the nearest
|
|
215
|
-
half hour, or `None` when no valid tick time is available.
|
|
216
|
-
|
|
217
|
-
|
|
220
|
+
half hour, or `None` when no valid tick time is available. Use it to validate a
|
|
221
|
+
suspected offset for the live `symbol_info_tick()` source before applying the
|
|
222
|
+
result in downstream time-window calculations.
|
|
218
223
|
|
|
219
224
|
No offset is applied automatically by history retrieval helpers.
|
|
220
225
|
|
|
@@ -6,11 +6,10 @@ import json
|
|
|
6
6
|
import logging
|
|
7
7
|
from contextlib import closing
|
|
8
8
|
from dataclasses import dataclass
|
|
9
|
-
from datetime import datetime
|
|
9
|
+
from datetime import datetime # noqa: TC003
|
|
10
10
|
from pathlib import Path # noqa: TC003
|
|
11
11
|
from typing import TYPE_CHECKING, Annotated, Any, cast
|
|
12
12
|
|
|
13
|
-
import click
|
|
14
13
|
import pandas as pd
|
|
15
14
|
import typer
|
|
16
15
|
|
|
@@ -46,125 +45,34 @@ if TYPE_CHECKING:
|
|
|
46
45
|
|
|
47
46
|
logger = logging.getLogger(__name__)
|
|
48
47
|
|
|
49
|
-
# ---------------------------------------------------------------------------
|
|
50
|
-
# Click parameter types
|
|
51
|
-
# ---------------------------------------------------------------------------
|
|
52
48
|
|
|
49
|
+
def _parse_datetime_parameter(value: str) -> datetime:
|
|
50
|
+
try:
|
|
51
|
+
return parse_datetime(value)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_timeframe_parameter(value: str) -> int:
|
|
57
|
+
try:
|
|
58
|
+
return parse_timeframe(value)
|
|
59
|
+
except ValueError as exc:
|
|
60
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_tick_flags_parameter(value: str) -> int:
|
|
64
|
+
try:
|
|
65
|
+
return parse_tick_flags(value)
|
|
66
|
+
except ValueError as exc:
|
|
67
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_request_parameter(value: str) -> dict[str, Any]:
|
|
71
|
+
try:
|
|
72
|
+
return parse_request(value)
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
53
75
|
|
|
54
|
-
class _DateTimeType(click.ParamType):
|
|
55
|
-
"""Click parameter type for ISO 8601 datetime strings."""
|
|
56
|
-
|
|
57
|
-
name = "DATETIME"
|
|
58
|
-
|
|
59
|
-
def convert(
|
|
60
|
-
self,
|
|
61
|
-
value: object,
|
|
62
|
-
param: click.Parameter | None,
|
|
63
|
-
ctx: click.Context | None,
|
|
64
|
-
) -> datetime:
|
|
65
|
-
"""Convert a string value to a timezone-aware datetime.
|
|
66
|
-
|
|
67
|
-
Args:
|
|
68
|
-
value: Raw value from the command line.
|
|
69
|
-
param: Click parameter instance.
|
|
70
|
-
ctx: Click context.
|
|
71
|
-
|
|
72
|
-
Returns:
|
|
73
|
-
Parsed datetime.
|
|
74
|
-
"""
|
|
75
|
-
if isinstance(value, datetime):
|
|
76
|
-
return value
|
|
77
|
-
try:
|
|
78
|
-
return parse_datetime(str(value))
|
|
79
|
-
except ValueError as exc:
|
|
80
|
-
self.fail(str(exc), param, ctx)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
class _TimeframeType(click.ParamType):
|
|
84
|
-
"""Click parameter type for MT5 timeframe values."""
|
|
85
|
-
|
|
86
|
-
name = "TIMEFRAME"
|
|
87
|
-
|
|
88
|
-
def convert(
|
|
89
|
-
self,
|
|
90
|
-
value: object,
|
|
91
|
-
param: click.Parameter | None,
|
|
92
|
-
ctx: click.Context | None,
|
|
93
|
-
) -> int:
|
|
94
|
-
"""Convert a string or integer value to a timeframe integer.
|
|
95
|
-
|
|
96
|
-
Args:
|
|
97
|
-
value: Raw value from the command line.
|
|
98
|
-
param: Click parameter instance.
|
|
99
|
-
ctx: Click context.
|
|
100
|
-
|
|
101
|
-
Returns:
|
|
102
|
-
Integer timeframe value.
|
|
103
|
-
"""
|
|
104
|
-
try:
|
|
105
|
-
return parse_timeframe(value)
|
|
106
|
-
except ValueError as exc:
|
|
107
|
-
self.fail(str(exc), param, ctx)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
class _TickFlagsType(click.ParamType):
|
|
111
|
-
"""Click parameter type for MT5 tick copy flags."""
|
|
112
|
-
|
|
113
|
-
name = "FLAGS"
|
|
114
|
-
|
|
115
|
-
def convert(
|
|
116
|
-
self,
|
|
117
|
-
value: object,
|
|
118
|
-
param: click.Parameter | None,
|
|
119
|
-
ctx: click.Context | None,
|
|
120
|
-
) -> int:
|
|
121
|
-
"""Convert a string or integer value to a tick flags integer.
|
|
122
|
-
|
|
123
|
-
Args:
|
|
124
|
-
value: Raw value from the command line.
|
|
125
|
-
param: Click parameter instance.
|
|
126
|
-
ctx: Click context.
|
|
127
|
-
|
|
128
|
-
Returns:
|
|
129
|
-
Integer tick flag value.
|
|
130
|
-
"""
|
|
131
|
-
try:
|
|
132
|
-
return parse_tick_flags(value)
|
|
133
|
-
except ValueError as exc:
|
|
134
|
-
self.fail(str(exc), param, ctx)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
class _RequestType(click.ParamType):
|
|
138
|
-
"""Click parameter type for JSON order requests."""
|
|
139
|
-
|
|
140
|
-
name = "REQUEST"
|
|
141
|
-
|
|
142
|
-
def convert(
|
|
143
|
-
self,
|
|
144
|
-
value: object,
|
|
145
|
-
param: click.Parameter | None,
|
|
146
|
-
ctx: click.Context | None,
|
|
147
|
-
) -> dict[str, Any]:
|
|
148
|
-
"""Convert a raw CLI value to an order request dictionary.
|
|
149
|
-
|
|
150
|
-
Args:
|
|
151
|
-
value: Raw value from the command line.
|
|
152
|
-
param: Click parameter instance.
|
|
153
|
-
ctx: Click context.
|
|
154
|
-
|
|
155
|
-
Returns:
|
|
156
|
-
Parsed request dictionary.
|
|
157
|
-
"""
|
|
158
|
-
try:
|
|
159
|
-
return parse_request(str(value))
|
|
160
|
-
except ValueError as exc:
|
|
161
|
-
self.fail(str(exc), param, ctx)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
DATETIME_TYPE = _DateTimeType()
|
|
165
|
-
TIMEFRAME_TYPE = _TimeframeType()
|
|
166
|
-
TICK_FLAGS_TYPE = _TickFlagsType()
|
|
167
|
-
REQUEST_TYPE = _RequestType()
|
|
168
76
|
|
|
169
77
|
# ---------------------------------------------------------------------------
|
|
170
78
|
# Export context
|
|
@@ -363,14 +271,14 @@ def rates_from(
|
|
|
363
271
|
timeframe: Annotated[
|
|
364
272
|
int,
|
|
365
273
|
typer.Option(
|
|
366
|
-
|
|
274
|
+
parser=_parse_timeframe_parameter,
|
|
367
275
|
help="Timeframe (e.g., M1, H1, D1, or integer).",
|
|
368
276
|
),
|
|
369
277
|
],
|
|
370
278
|
date_from: Annotated[
|
|
371
279
|
datetime,
|
|
372
280
|
typer.Option(
|
|
373
|
-
|
|
281
|
+
parser=_parse_datetime_parameter,
|
|
374
282
|
help="Start date in ISO 8601 format.",
|
|
375
283
|
),
|
|
376
284
|
],
|
|
@@ -390,7 +298,7 @@ def rates_from_pos(
|
|
|
390
298
|
timeframe: Annotated[
|
|
391
299
|
int,
|
|
392
300
|
typer.Option(
|
|
393
|
-
|
|
301
|
+
parser=_parse_timeframe_parameter,
|
|
394
302
|
help="Timeframe.",
|
|
395
303
|
),
|
|
396
304
|
],
|
|
@@ -416,7 +324,7 @@ def latest_rates(
|
|
|
416
324
|
timeframe: Annotated[
|
|
417
325
|
int,
|
|
418
326
|
typer.Option(
|
|
419
|
-
|
|
327
|
+
parser=_parse_timeframe_parameter,
|
|
420
328
|
help="Timeframe.",
|
|
421
329
|
),
|
|
422
330
|
],
|
|
@@ -445,17 +353,17 @@ def rates_range(
|
|
|
445
353
|
timeframe: Annotated[
|
|
446
354
|
int,
|
|
447
355
|
typer.Option(
|
|
448
|
-
|
|
356
|
+
parser=_parse_timeframe_parameter,
|
|
449
357
|
help="Timeframe.",
|
|
450
358
|
),
|
|
451
359
|
],
|
|
452
360
|
date_from: Annotated[
|
|
453
361
|
datetime,
|
|
454
|
-
typer.Option(
|
|
362
|
+
typer.Option(parser=_parse_datetime_parameter, help="Start date."),
|
|
455
363
|
],
|
|
456
364
|
date_to: Annotated[
|
|
457
365
|
datetime,
|
|
458
|
-
typer.Option(
|
|
366
|
+
typer.Option(parser=_parse_datetime_parameter, help="End date."),
|
|
459
367
|
],
|
|
460
368
|
) -> None:
|
|
461
369
|
"""Export rates for a date range."""
|
|
@@ -471,13 +379,13 @@ def ticks_from(
|
|
|
471
379
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
|
472
380
|
date_from: Annotated[
|
|
473
381
|
datetime,
|
|
474
|
-
typer.Option(
|
|
382
|
+
typer.Option(parser=_parse_datetime_parameter, help="Start date."),
|
|
475
383
|
],
|
|
476
384
|
count: Annotated[int, typer.Option(help="Number of ticks.")],
|
|
477
385
|
flags: Annotated[
|
|
478
386
|
int,
|
|
479
387
|
typer.Option(
|
|
480
|
-
|
|
388
|
+
parser=_parse_tick_flags_parameter,
|
|
481
389
|
help="Tick flags (ALL, INFO, TRADE, or integer).",
|
|
482
390
|
),
|
|
483
391
|
],
|
|
@@ -495,15 +403,15 @@ def ticks_range(
|
|
|
495
403
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
|
496
404
|
date_from: Annotated[
|
|
497
405
|
datetime,
|
|
498
|
-
typer.Option(
|
|
406
|
+
typer.Option(parser=_parse_datetime_parameter, help="Start date."),
|
|
499
407
|
],
|
|
500
408
|
date_to: Annotated[
|
|
501
409
|
datetime,
|
|
502
|
-
typer.Option(
|
|
410
|
+
typer.Option(parser=_parse_datetime_parameter, help="End date."),
|
|
503
411
|
],
|
|
504
412
|
flags: Annotated[
|
|
505
413
|
int,
|
|
506
|
-
typer.Option(
|
|
414
|
+
typer.Option(parser=_parse_tick_flags_parameter, help="Tick flags."),
|
|
507
415
|
],
|
|
508
416
|
) -> None:
|
|
509
417
|
"""Export ticks for a date range."""
|
|
@@ -523,7 +431,7 @@ def ticks_recent(
|
|
|
523
431
|
],
|
|
524
432
|
date_to: Annotated[
|
|
525
433
|
datetime | None,
|
|
526
|
-
typer.Option(
|
|
434
|
+
typer.Option(parser=_parse_datetime_parameter, help="Window end date."),
|
|
527
435
|
] = None,
|
|
528
436
|
count: Annotated[
|
|
529
437
|
int,
|
|
@@ -532,7 +440,7 @@ def ticks_recent(
|
|
|
532
440
|
flags: Annotated[
|
|
533
441
|
int,
|
|
534
442
|
typer.Option(
|
|
535
|
-
|
|
443
|
+
parser=_parse_tick_flags_parameter,
|
|
536
444
|
help="Tick flags (ALL, INFO, TRADE, or integer).",
|
|
537
445
|
),
|
|
538
446
|
] = "ALL", # pyright: ignore[reportArgumentType]
|
|
@@ -625,11 +533,11 @@ def history_orders(
|
|
|
625
533
|
ctx: typer.Context,
|
|
626
534
|
date_from: Annotated[
|
|
627
535
|
datetime | None,
|
|
628
|
-
typer.Option(
|
|
536
|
+
typer.Option(parser=_parse_datetime_parameter, help="Start date."),
|
|
629
537
|
] = None,
|
|
630
538
|
date_to: Annotated[
|
|
631
539
|
datetime | None,
|
|
632
|
-
typer.Option(
|
|
540
|
+
typer.Option(parser=_parse_datetime_parameter, help="End date."),
|
|
633
541
|
] = None,
|
|
634
542
|
group: Annotated[str | None, typer.Option(help="Group filter.")] = None,
|
|
635
543
|
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
|
@@ -655,11 +563,11 @@ def history_deals(
|
|
|
655
563
|
ctx: typer.Context,
|
|
656
564
|
date_from: Annotated[
|
|
657
565
|
datetime | None,
|
|
658
|
-
typer.Option(
|
|
566
|
+
typer.Option(parser=_parse_datetime_parameter, help="Start date."),
|
|
659
567
|
] = None,
|
|
660
568
|
date_to: Annotated[
|
|
661
569
|
datetime | None,
|
|
662
|
-
typer.Option(
|
|
570
|
+
typer.Option(parser=_parse_datetime_parameter, help="End date."),
|
|
663
571
|
] = None,
|
|
664
572
|
group: Annotated[str | None, typer.Option(help="Group filter.")] = None,
|
|
665
573
|
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
|
@@ -686,7 +594,7 @@ def recent_history_deals(
|
|
|
686
594
|
hours: Annotated[float, typer.Option(help="Lookback window in hours.")],
|
|
687
595
|
date_to: Annotated[
|
|
688
596
|
datetime | None,
|
|
689
|
-
typer.Option(
|
|
597
|
+
typer.Option(parser=_parse_datetime_parameter, help="Window end date."),
|
|
690
598
|
] = None,
|
|
691
599
|
group: Annotated[str | None, typer.Option(help="Group filter.")] = None,
|
|
692
600
|
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
|
@@ -744,7 +652,7 @@ def order_check(
|
|
|
744
652
|
ctx: typer.Context,
|
|
745
653
|
request: Annotated[
|
|
746
654
|
dict[str, Any],
|
|
747
|
-
typer.Option(
|
|
655
|
+
typer.Option(parser=_parse_request_parameter, help=_REQUEST_OPTION_HELP),
|
|
748
656
|
],
|
|
749
657
|
) -> None:
|
|
750
658
|
"""Check funds sufficiency for a trading operation."""
|
|
@@ -756,7 +664,7 @@ def order_send(
|
|
|
756
664
|
ctx: typer.Context,
|
|
757
665
|
request: Annotated[
|
|
758
666
|
dict[str, Any],
|
|
759
|
-
typer.Option(
|
|
667
|
+
typer.Option(parser=_parse_request_parameter, help=_REQUEST_OPTION_HELP),
|
|
760
668
|
],
|
|
761
669
|
yes: Annotated[
|
|
762
670
|
bool,
|
|
@@ -995,11 +903,11 @@ def collect_history(
|
|
|
995
903
|
],
|
|
996
904
|
date_from: Annotated[
|
|
997
905
|
datetime,
|
|
998
|
-
typer.Option(
|
|
906
|
+
typer.Option(parser=_parse_datetime_parameter, help="Start date."),
|
|
999
907
|
],
|
|
1000
908
|
date_to: Annotated[
|
|
1001
909
|
datetime,
|
|
1002
|
-
typer.Option(
|
|
910
|
+
typer.Option(parser=_parse_datetime_parameter, help="End date."),
|
|
1003
911
|
],
|
|
1004
912
|
dataset: Annotated[
|
|
1005
913
|
list[Dataset] | None,
|
|
@@ -1016,14 +924,14 @@ def collect_history(
|
|
|
1016
924
|
timeframe: Annotated[
|
|
1017
925
|
int,
|
|
1018
926
|
typer.Option(
|
|
1019
|
-
|
|
927
|
+
parser=_parse_timeframe_parameter,
|
|
1020
928
|
help="Rates timeframe (e.g., M1, H1, D1).",
|
|
1021
929
|
),
|
|
1022
930
|
] = 1,
|
|
1023
931
|
flags: Annotated[
|
|
1024
932
|
int,
|
|
1025
933
|
typer.Option(
|
|
1026
|
-
|
|
934
|
+
parser=_parse_tick_flags_parameter,
|
|
1027
935
|
help="Tick copy flags (ALL, INFO, TRADE, or integer).",
|
|
1028
936
|
),
|
|
1029
937
|
] = "ALL", # pyright: ignore[reportArgumentType]
|
|
@@ -355,12 +355,14 @@ def _shutdown_client(client: Mt5DataClient, *, raise_on_error: bool) -> None:
|
|
|
355
355
|
failure is raised as the stable normalized exception. When False (an
|
|
356
356
|
initialization or session-body exception is already propagating), the
|
|
357
357
|
failure is logged so the primary exception passes through unchanged.
|
|
358
|
-
|
|
358
|
+
|
|
359
|
+
"""
|
|
359
360
|
try:
|
|
360
361
|
client.shutdown()
|
|
361
362
|
except Exception as exc:
|
|
362
363
|
if raise_on_error:
|
|
363
|
-
|
|
364
|
+
normalized = normalize_mt5_exception(exc)
|
|
365
|
+
raise normalized from exc
|
|
364
366
|
logger.warning("MT5 shutdown failed during cleanup", exc_info=True)
|
|
365
367
|
|
|
366
368
|
|
|
@@ -385,7 +387,7 @@ def _connected_client(
|
|
|
385
387
|
Yields:
|
|
386
388
|
Connected ``Mt5DataClient`` instance.
|
|
387
389
|
|
|
388
|
-
"""
|
|
390
|
+
"""
|
|
389
391
|
client = (
|
|
390
392
|
Mt5DataClient(config=config)
|
|
391
393
|
if retry_count is None
|
|
@@ -395,7 +397,8 @@ def _connected_client(
|
|
|
395
397
|
client.initialize_and_login_mt5()
|
|
396
398
|
except Exception as exc:
|
|
397
399
|
_shutdown_client(client, raise_on_error=False)
|
|
398
|
-
|
|
400
|
+
normalized = normalize_mt5_exception(exc)
|
|
401
|
+
raise normalized from exc
|
|
399
402
|
try:
|
|
400
403
|
yield client
|
|
401
404
|
except BaseException:
|
|
@@ -420,12 +423,14 @@ def _run_with_client(
|
|
|
420
423
|
|
|
421
424
|
Returns:
|
|
422
425
|
Value returned by ``fetch_fn``.
|
|
423
|
-
|
|
426
|
+
|
|
427
|
+
"""
|
|
424
428
|
with _connected_client(config, retry_count=retry_count) as client:
|
|
425
429
|
try:
|
|
426
430
|
return fetch_fn(client)
|
|
427
431
|
except Mt5RuntimeError as exc:
|
|
428
|
-
|
|
432
|
+
normalized = normalize_mt5_exception(exc)
|
|
433
|
+
raise normalized from exc
|
|
429
434
|
|
|
430
435
|
|
|
431
436
|
class _BaseMT5Client:
|
|
@@ -480,7 +485,8 @@ class _BaseMT5Client:
|
|
|
480
485
|
|
|
481
486
|
Returns:
|
|
482
487
|
This client instance.
|
|
483
|
-
|
|
488
|
+
|
|
489
|
+
"""
|
|
484
490
|
if self._client is not None:
|
|
485
491
|
return self
|
|
486
492
|
client = Mt5DataClient(config=self._config, retry_count=self._retry_count)
|
|
@@ -488,7 +494,8 @@ class _BaseMT5Client:
|
|
|
488
494
|
client.initialize_and_login_mt5()
|
|
489
495
|
except Exception as exc:
|
|
490
496
|
_shutdown_client(client, raise_on_error=False)
|
|
491
|
-
|
|
497
|
+
normalized = normalize_mt5_exception(exc)
|
|
498
|
+
raise normalized from exc
|
|
492
499
|
self._client = client
|
|
493
500
|
self._owns_client = True # only set when this method created the client
|
|
494
501
|
return self
|
|
@@ -787,6 +794,31 @@ class _BaseMT5Client:
|
|
|
787
794
|
"""Return the last tick for a symbol."""
|
|
788
795
|
return self._fetch(lambda c: c.symbol_info_tick_as_df(symbol=symbol))
|
|
789
796
|
|
|
797
|
+
def symbol_info_tick_as_dict(
|
|
798
|
+
self,
|
|
799
|
+
symbol: str,
|
|
800
|
+
*,
|
|
801
|
+
skip_to_datetime: bool = False,
|
|
802
|
+
) -> dict[str, object]:
|
|
803
|
+
"""Return the last tick as a plain mapping.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
symbol: Symbol name.
|
|
807
|
+
skip_to_datetime: Preserve numeric MT5 time fields when ``True``.
|
|
808
|
+
|
|
809
|
+
Returns:
|
|
810
|
+
Tick fields using the underlying pdmt5 conversion behavior.
|
|
811
|
+
"""
|
|
812
|
+
return self._fetch_value(
|
|
813
|
+
lambda c: cast(
|
|
814
|
+
"dict[str, object]",
|
|
815
|
+
c.symbol_info_tick_as_dict(
|
|
816
|
+
symbol=symbol,
|
|
817
|
+
skip_to_datetime=skip_to_datetime,
|
|
818
|
+
),
|
|
819
|
+
)
|
|
820
|
+
)
|
|
821
|
+
|
|
790
822
|
def market_book(self, symbol: str) -> pd.DataFrame:
|
|
791
823
|
"""Return market depth for a symbol."""
|
|
792
824
|
return self._fetch(lambda c: c.market_book_get_as_df(symbol=symbol))
|
|
@@ -2755,11 +2755,7 @@ class ThrottledHistoryUpdater:
|
|
|
2755
2755
|
When ``suppress_errors`` is False, recoverable update failures
|
|
2756
2756
|
propagate to the caller.
|
|
2757
2757
|
|
|
2758
|
-
|
|
2759
|
-
Mt5ConnectionError: When ``suppress_errors`` is False and the
|
|
2760
|
-
update fails with a recoverable MT5, SQLite, validation, or
|
|
2761
|
-
I/O error.
|
|
2762
|
-
""" # noqa: DOC502
|
|
2758
|
+
"""
|
|
2763
2759
|
if not self.should_update():
|
|
2764
2760
|
return False
|
|
2765
2761
|
try:
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import logging
|
|
6
6
|
from dataclasses import asdict, dataclass
|
|
7
7
|
from datetime import UTC, datetime
|
|
8
|
+
from inspect import signature
|
|
8
9
|
from math import floor, isfinite
|
|
9
10
|
from numbers import Integral, Real
|
|
10
11
|
from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, cast
|
|
@@ -20,6 +21,7 @@ if TYPE_CHECKING:
|
|
|
20
21
|
from typing import Any
|
|
21
22
|
|
|
22
23
|
_logger = logging.getLogger(__name__)
|
|
24
|
+
_DATETIME_TYPES = (datetime, pd.Timestamp)
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
class _Mt5ClientProtocol(Protocol):
|
|
@@ -801,16 +803,57 @@ def get_tick_snapshot(
|
|
|
801
803
|
client: _Mt5ClientProtocol,
|
|
802
804
|
symbol: str,
|
|
803
805
|
) -> dict[str, float | int | None]:
|
|
804
|
-
"""Return normalized latest tick data
|
|
806
|
+
"""Return normalized latest tick data with a numeric MT5 timestamp.
|
|
807
|
+
|
|
808
|
+
The numeric ``time`` preserves the original value returned by MT5 without
|
|
809
|
+
applying timezone or server-offset correction.
|
|
810
|
+
"""
|
|
805
811
|
method = getattr(client, "symbol_info_tick_as_dict", None)
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
812
|
+
if callable(method):
|
|
813
|
+
try:
|
|
814
|
+
signature(method).bind(symbol=symbol, skip_to_datetime=True)
|
|
815
|
+
except (TypeError, ValueError):
|
|
816
|
+
value = method(symbol=symbol)
|
|
817
|
+
else:
|
|
818
|
+
value = method(symbol=symbol, skip_to_datetime=True)
|
|
819
|
+
else:
|
|
820
|
+
value = client.symbol_info_tick(symbol)
|
|
809
821
|
snapshot = _snapshot_from_value(value, _TICK_SNAPSHOT_FIELDS)
|
|
810
822
|
snapshot["symbol"] = snapshot.get("symbol") or symbol
|
|
823
|
+
snapshot["time"] = _numeric_tick_time(snapshot.get("time"))
|
|
811
824
|
return cast("dict[str, float | int | None]", snapshot)
|
|
812
825
|
|
|
813
826
|
|
|
827
|
+
def _numeric_tick_time(value: object) -> float | int | None:
|
|
828
|
+
"""Normalize an MT5 timestamp to its numeric epoch representation.
|
|
829
|
+
|
|
830
|
+
Returns:
|
|
831
|
+
The numeric timestamp, or ``None`` for an unsupported value.
|
|
832
|
+
"""
|
|
833
|
+
numeric: float | None
|
|
834
|
+
if value is None or isinstance(value, bool):
|
|
835
|
+
numeric = None
|
|
836
|
+
elif isinstance(value, _DATETIME_TYPES):
|
|
837
|
+
if pd.isna(value):
|
|
838
|
+
return None
|
|
839
|
+
timestamp = pd.Timestamp(value)
|
|
840
|
+
if timestamp.tzinfo is None:
|
|
841
|
+
timestamp = timestamp.tz_localize(UTC)
|
|
842
|
+
numeric = timestamp.timestamp()
|
|
843
|
+
elif isinstance(value, Integral):
|
|
844
|
+
return int(value)
|
|
845
|
+
elif isinstance(value, Real):
|
|
846
|
+
numeric = float(value)
|
|
847
|
+
elif isinstance(value, str):
|
|
848
|
+
try:
|
|
849
|
+
numeric = float(value)
|
|
850
|
+
except ValueError:
|
|
851
|
+
return None
|
|
852
|
+
else:
|
|
853
|
+
numeric = None
|
|
854
|
+
return numeric if numeric is not None and isfinite(numeric) else None
|
|
855
|
+
|
|
856
|
+
|
|
814
857
|
_SERVER_CLOCK_OFFSET_ROUNDING_SECONDS = 1800.0
|
|
815
858
|
_MAX_PLAUSIBLE_SERVER_CLOCK_OFFSET_SECONDS = 14 * 3600.0
|
|
816
859
|
|
|
@@ -821,12 +864,12 @@ def estimate_server_clock_offset_seconds(
|
|
|
821
864
|
) -> float | None:
|
|
822
865
|
"""Estimate the broker server clock offset from true UTC.
|
|
823
866
|
|
|
824
|
-
MT5 tick
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
867
|
+
MT5 documents copied tick and bar data as UTC, while the timezone contract
|
|
868
|
+
for ``symbol_info_tick()`` is not explicit. This helper reads the latest
|
|
869
|
+
tick for ``symbol`` to validate a suspected broker-specific clock offset
|
|
870
|
+
before downstream correction. It compares the tick timestamp to the
|
|
871
|
+
current UTC time and rounds to the nearest half hour, absorbing a few
|
|
872
|
+
minutes of real tick staleness.
|
|
830
873
|
|
|
831
874
|
On a closed market, weekend, holiday, or illiquid symbol the latest tick
|
|
832
875
|
can be hours or days old, in which case the tick-vs-now delta reflects
|