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.
Files changed (73) hide show
  1. {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/workflows/ci.yml +15 -0
  2. {mt5cli-1.2.2 → mt5cli-1.2.4}/AGENTS.md +1 -1
  3. {mt5cli-1.2.2 → mt5cli-1.2.4}/PKG-INFO +2 -3
  4. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/trading.md +14 -9
  5. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/cli.py +52 -144
  6. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/client.py +40 -8
  7. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/history.py +1 -5
  8. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/trading.py +53 -10
  9. {mt5cli-1.2.2 → mt5cli-1.2.4}/pyproject.toml +66 -65
  10. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/conftest.py +2 -1
  11. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_cli.py +20 -85
  12. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_client.py +4 -10
  13. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_contracts.py +6 -3
  14. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_history.py +2 -1
  15. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_telemetry.py +4 -1
  16. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_trading.py +104 -6
  17. {mt5cli-1.2.2 → mt5cli-1.2.4}/uv.lock +9 -11
  18. {mt5cli-1.2.2 → mt5cli-1.2.4}/.agents/skills/local-qa/SKILL.md +0 -0
  19. {mt5cli-1.2.2 → mt5cli-1.2.4}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  20. {mt5cli-1.2.2 → mt5cli-1.2.4}/.agents/skills/mt5cli/SKILL.md +0 -0
  21. {mt5cli-1.2.2 → mt5cli-1.2.4}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  22. {mt5cli-1.2.2 → mt5cli-1.2.4}/.claude/agents/codex.md +0 -0
  23. {mt5cli-1.2.2 → mt5cli-1.2.4}/.claude/settings.json +0 -0
  24. {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/FUNDING.yml +0 -0
  25. {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/dependabot.yml +0 -0
  26. {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/renovate.json +0 -0
  27. {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/workflows/claude.yml +0 -0
  28. {mt5cli-1.2.2 → mt5cli-1.2.4}/.github/workflows/release.yml +0 -0
  29. {mt5cli-1.2.2 → mt5cli-1.2.4}/.gitignore +0 -0
  30. {mt5cli-1.2.2 → mt5cli-1.2.4}/CLAUDE.md +0 -0
  31. {mt5cli-1.2.2 → mt5cli-1.2.4}/LICENSE +0 -0
  32. {mt5cli-1.2.2 → mt5cli-1.2.4}/README.md +0 -0
  33. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/cli.md +0 -0
  34. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/client.md +0 -0
  35. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/converters.md +0 -0
  36. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/exceptions.md +0 -0
  37. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/grafana.md +0 -0
  38. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/history.md +0 -0
  39. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/index.md +0 -0
  40. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/marketdata.md +0 -0
  41. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/observability.md +0 -0
  42. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/public-contract.md +0 -0
  43. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/schemas.md +0 -0
  44. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/telemetry.md +0 -0
  45. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/api/utils.md +0 -0
  46. {mt5cli-1.2.2 → mt5cli-1.2.4}/docs/index.md +0 -0
  47. {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/README.md +0 -0
  48. {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/compose.yml +0 -0
  49. {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/dashboards/mt5cli-market.json +0 -0
  50. {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
  51. {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
  52. {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
  53. {mt5cli-1.2.2 → mt5cli-1.2.4}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
  54. {mt5cli-1.2.2 → mt5cli-1.2.4}/mkdocs.yml +0 -0
  55. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/__init__.py +0 -0
  56. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/__main__.py +0 -0
  57. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/contract.py +0 -0
  58. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/converters.py +0 -0
  59. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/exceptions.py +0 -0
  60. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/grafana.py +0 -0
  61. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/marketdata.py +0 -0
  62. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/observability.py +0 -0
  63. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/retry.py +0 -0
  64. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/schemas.py +0 -0
  65. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/telemetry.py +0 -0
  66. {mt5cli-1.2.2 → mt5cli-1.2.4}/mt5cli/utils.py +0 -0
  67. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/__init__.py +0 -0
  68. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_examples.py +0 -0
  69. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_grafana.py +0 -0
  70. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_marketdata.py +0 -0
  71. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_observability.py +0 -0
  72. {mt5cli-1.2.2 → mt5cli-1.2.4}/tests/test_packaging.py +0 -0
  73. {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.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.15.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
- ## Broker server clock offset
205
+ ## Timestamp sources and broker clock offsets
206
206
 
207
- MT5 tick, bar, and deal timestamps are epoch values labeled in the broker
208
- server's wall clock, which is commonly UTC+2 or UTC+3 rather than true UTC.
209
- Any code that mixes those timestamps with true UTC (freshness checks,
210
- trailing history windows) silently inherits the broker's offset as a bias
211
- unless it is measured and applied explicitly.
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. Apply the result in
216
- the downstream time-window calculation when comparing broker timestamps to
217
- true UTC.
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
- click_type=TIMEFRAME_TYPE,
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
- click_type=DATETIME_TYPE,
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
- click_type=TIMEFRAME_TYPE,
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
- click_type=TIMEFRAME_TYPE,
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
- click_type=TIMEFRAME_TYPE,
356
+ parser=_parse_timeframe_parameter,
449
357
  help="Timeframe.",
450
358
  ),
451
359
  ],
452
360
  date_from: Annotated[
453
361
  datetime,
454
- typer.Option(click_type=DATETIME_TYPE, help="Start date."),
362
+ typer.Option(parser=_parse_datetime_parameter, help="Start date."),
455
363
  ],
456
364
  date_to: Annotated[
457
365
  datetime,
458
- typer.Option(click_type=DATETIME_TYPE, help="End date."),
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(click_type=DATETIME_TYPE, help="Start date."),
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
- click_type=TICK_FLAGS_TYPE,
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(click_type=DATETIME_TYPE, help="Start date."),
406
+ typer.Option(parser=_parse_datetime_parameter, help="Start date."),
499
407
  ],
500
408
  date_to: Annotated[
501
409
  datetime,
502
- typer.Option(click_type=DATETIME_TYPE, help="End date."),
410
+ typer.Option(parser=_parse_datetime_parameter, help="End date."),
503
411
  ],
504
412
  flags: Annotated[
505
413
  int,
506
- typer.Option(click_type=TICK_FLAGS_TYPE, help="Tick flags."),
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(click_type=DATETIME_TYPE, help="Window end date."),
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
- click_type=TICK_FLAGS_TYPE,
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(click_type=DATETIME_TYPE, help="Start date."),
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(click_type=DATETIME_TYPE, help="End date."),
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(click_type=DATETIME_TYPE, help="Start date."),
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(click_type=DATETIME_TYPE, help="End date."),
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(click_type=DATETIME_TYPE, help="Window end date."),
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(click_type=REQUEST_TYPE, help=_REQUEST_OPTION_HELP),
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(click_type=REQUEST_TYPE, help=_REQUEST_OPTION_HELP),
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(click_type=DATETIME_TYPE, help="Start date."),
906
+ typer.Option(parser=_parse_datetime_parameter, help="Start date."),
999
907
  ],
1000
908
  date_to: Annotated[
1001
909
  datetime,
1002
- typer.Option(click_type=DATETIME_TYPE, help="End date."),
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
- click_type=TIMEFRAME_TYPE,
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
- click_type=TICK_FLAGS_TYPE,
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
- """ # noqa: DOC501
358
+
359
+ """
359
360
  try:
360
361
  client.shutdown()
361
362
  except Exception as exc:
362
363
  if raise_on_error:
363
- raise normalize_mt5_exception(exc) from exc
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
- """ # noqa: DOC501
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
- raise normalize_mt5_exception(exc) from exc
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
- """ # noqa: DOC501
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
- raise normalize_mt5_exception(exc) from exc
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
- """ # noqa: DOC501
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
- raise normalize_mt5_exception(exc) from exc
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
- Raises:
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, including bid, ask, and timestamp."""
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
- value = (
807
- method(symbol=symbol) if callable(method) else client.symbol_info_tick(symbol)
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, bar, and deal timestamps are epoch values labeled in the
825
- broker server's wall clock, which is commonly UTC+2 or UTC+3 rather
826
- than true UTC. This reads the latest tick for ``symbol`` and compares
827
- its timestamp to the current UTC time, rounding to the nearest half
828
- hour: server offsets are whole or half-hour multiples, and rounding
829
- absorbs a few minutes of real tick staleness.
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