mt5cli 1.2.3__tar.gz → 1.3.0__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.3 → mt5cli-1.3.0}/.github/workflows/ci.yml +2 -2
  2. {mt5cli-1.2.3 → mt5cli-1.3.0}/PKG-INFO +1 -2
  3. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/public-contract.md +4 -1
  4. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/trading.md +98 -7
  5. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/__init__.py +5 -1
  6. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/cli.py +1 -122
  7. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/client.py +15 -8
  8. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/contract.py +5 -1
  9. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/history.py +35 -11
  10. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/trading.py +695 -59
  11. {mt5cli-1.2.3 → mt5cli-1.3.0}/pyproject.toml +65 -64
  12. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/conftest.py +2 -1
  13. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_cli.py +13 -104
  14. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_client.py +4 -10
  15. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_contracts.py +6 -3
  16. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_history.py +167 -10
  17. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_telemetry.py +4 -1
  18. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_trading.py +902 -77
  19. {mt5cli-1.2.3 → mt5cli-1.3.0}/uv.lock +2 -4
  20. {mt5cli-1.2.3 → mt5cli-1.3.0}/.agents/skills/local-qa/SKILL.md +0 -0
  21. {mt5cli-1.2.3 → mt5cli-1.3.0}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  22. {mt5cli-1.2.3 → mt5cli-1.3.0}/.agents/skills/mt5cli/SKILL.md +0 -0
  23. {mt5cli-1.2.3 → mt5cli-1.3.0}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  24. {mt5cli-1.2.3 → mt5cli-1.3.0}/.claude/agents/codex.md +0 -0
  25. {mt5cli-1.2.3 → mt5cli-1.3.0}/.claude/settings.json +0 -0
  26. {mt5cli-1.2.3 → mt5cli-1.3.0}/.github/FUNDING.yml +0 -0
  27. {mt5cli-1.2.3 → mt5cli-1.3.0}/.github/dependabot.yml +0 -0
  28. {mt5cli-1.2.3 → mt5cli-1.3.0}/.github/renovate.json +0 -0
  29. {mt5cli-1.2.3 → mt5cli-1.3.0}/.github/workflows/claude.yml +0 -0
  30. {mt5cli-1.2.3 → mt5cli-1.3.0}/.github/workflows/release.yml +0 -0
  31. {mt5cli-1.2.3 → mt5cli-1.3.0}/.gitignore +0 -0
  32. {mt5cli-1.2.3 → mt5cli-1.3.0}/AGENTS.md +0 -0
  33. {mt5cli-1.2.3 → mt5cli-1.3.0}/CLAUDE.md +0 -0
  34. {mt5cli-1.2.3 → mt5cli-1.3.0}/LICENSE +0 -0
  35. {mt5cli-1.2.3 → mt5cli-1.3.0}/README.md +0 -0
  36. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/cli.md +0 -0
  37. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/client.md +0 -0
  38. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/converters.md +0 -0
  39. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/exceptions.md +0 -0
  40. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/grafana.md +0 -0
  41. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/history.md +0 -0
  42. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/index.md +0 -0
  43. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/marketdata.md +0 -0
  44. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/observability.md +0 -0
  45. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/schemas.md +0 -0
  46. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/telemetry.md +0 -0
  47. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/api/utils.md +0 -0
  48. {mt5cli-1.2.3 → mt5cli-1.3.0}/docs/index.md +0 -0
  49. {mt5cli-1.2.3 → mt5cli-1.3.0}/examples/grafana/README.md +0 -0
  50. {mt5cli-1.2.3 → mt5cli-1.3.0}/examples/grafana/compose.yml +0 -0
  51. {mt5cli-1.2.3 → mt5cli-1.3.0}/examples/grafana/dashboards/mt5cli-market.json +0 -0
  52. {mt5cli-1.2.3 → mt5cli-1.3.0}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
  53. {mt5cli-1.2.3 → mt5cli-1.3.0}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
  54. {mt5cli-1.2.3 → mt5cli-1.3.0}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
  55. {mt5cli-1.2.3 → mt5cli-1.3.0}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
  56. {mt5cli-1.2.3 → mt5cli-1.3.0}/mkdocs.yml +0 -0
  57. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/__main__.py +0 -0
  58. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/converters.py +0 -0
  59. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/exceptions.py +0 -0
  60. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/grafana.py +0 -0
  61. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/marketdata.py +0 -0
  62. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/observability.py +0 -0
  63. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/retry.py +0 -0
  64. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/schemas.py +0 -0
  65. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/telemetry.py +0 -0
  66. {mt5cli-1.2.3 → mt5cli-1.3.0}/mt5cli/utils.py +0 -0
  67. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/__init__.py +0 -0
  68. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_examples.py +0 -0
  69. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_grafana.py +0 -0
  70. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_marketdata.py +0 -0
  71. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_observability.py +0 -0
  72. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_packaging.py +0 -0
  73. {mt5cli-1.2.3 → mt5cli-1.3.0}/tests/test_utils.py +0 -0
@@ -59,10 +59,10 @@ jobs:
59
59
  contents: read
60
60
  runs-on: windows-latest
61
61
  steps:
62
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.2
62
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6.0.2
63
63
  with:
64
64
  persist-credentials: false
65
- - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
65
+ - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
66
66
  - name: Check CLI help with minimum Click and Typer versions
67
67
  run: uv run --with click==8.4.0 --with typer==0.26.0 mt5cli --help
68
68
  python-docs-deploy:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 1.2.3
3
+ Version: 1.3.0
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,7 +16,6 @@ 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.4
20
19
  Requires-Dist: pandas>=2.2.2
21
20
  Requires-Dist: pdmt5>=1.2.0
22
21
  Requires-Dist: typer>=0.26.0
@@ -102,7 +102,10 @@ strategy entries, exits, Kelly sizing, or signal logic.
102
102
  | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
103
103
  | `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
104
104
  | `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
105
- | `estimate_server_clock_offset_seconds` | Broker server clock offset from true UTC, via the latest tick |
105
+ | `TickClockNormalizer` | Connection-scoped, copied-tick-validated UTC tick normalization |
106
+ | `TickClockCalibration` | Server-clock calibration diagnostics (offset, evidence, status) |
107
+ | `NormalizedTickSnapshot` | Typed latest-tick contract with raw vs validated UTC timestamps |
108
+ | `CalibrationStatus`, `ClockStatus` | Literal status types for clock calibration and normalization |
106
109
  | `detect_position_side` | Net long / short / flat from open positions |
107
110
  | `calculate_spread_ratio` | Relative bid-ask spread |
108
111
  | `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
@@ -215,14 +215,105 @@ Any broker-specific offset handling therefore requires evidence from the live
215
215
  data source in use and must be applied separately. Do not subtract a presumed
216
216
  UTC+2 or UTC+3 offset from timestamps that are already UTC.
217
217
 
218
- `estimate_server_clock_offset_seconds()` reads the latest tick for a symbol
219
- and returns the broker's clock offset from true UTC, rounded to the nearest
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.
223
-
224
218
  No offset is applied automatically by history retrieval helpers.
225
219
 
220
+ ## UTC normalization for live ticks
221
+
222
+ `TickClockNormalizer` provides the stable API for obtaining a latest tick with
223
+ a **validated** UTC timestamp, so strategy applications never compare a raw
224
+ server-labeled tick time against `datetime.now(UTC)` themselves.
225
+
226
+ ```python
227
+ from mt5cli import TickClockNormalizer, mt5_session
228
+
229
+ with mt5_session() as client:
230
+ normalizer = TickClockNormalizer(client, ["SING30", "EURUSD"])
231
+ snapshot = normalizer.get_normalized_tick_snapshot("SING30")
232
+ if snapshot["clock_status"] == "calibrated":
233
+ freshness = now_utc - snapshot["time_utc"] # safe wall-clock compare
234
+ ```
235
+
236
+ A normalized snapshot keeps the raw/UTC distinction explicit:
237
+
238
+ ```python
239
+ {
240
+ "symbol": "SING30",
241
+ "bid": ...,
242
+ "ask": ...,
243
+ "last": ...,
244
+ "volume": ...,
245
+ "raw_time": 1717243200, # numeric MT5 epoch, as labeled by broker
246
+ "time_utc": datetime(..., tzinfo=UTC), # None unless calibrated
247
+ "server_clock_offset_seconds": 10800.0, # None unless calibrated
248
+ "clock_status": "calibrated", # or "uncalibrated"
249
+ }
250
+ ```
251
+
252
+ ### Calibration evidence
253
+
254
+ The offset is never inferred from `latest_tick_time - now` alone. Each
255
+ calibration sample fetches the live `symbol_info_tick()` value **and** recent
256
+ UTC-labeled `copy_ticks_range()` data, then searches every recent copied row
257
+ (not just the newest) for one that matches the live tick on its shared
258
+ `bid`/`ask`/`last`/`volume` fields (with at least one agreeing positive price
259
+ field); the newest matching row sets the resulting offset, which must align
260
+ to a 30-minute increment within the realistic UTC-14..UTC+14 range.
261
+ `time_msc` is preferred over second-resolution `time` on both sides when
262
+ available.
263
+
264
+ An offset is accepted only after `min_agreeing_samples` (default 2)
265
+ **distinct** matched tick events agree on the same rounded offset — repeated
266
+ observations of one unchanged tick never count twice, so an illiquid symbol
267
+ cannot self-confirm. Evidence can come from multiple samples of one actively
268
+ updating symbol (paced by `sample_interval_seconds`) or across several
269
+ symbols passed to the constructor. Any two matched samples that disagree
270
+ abort the calibration (`offset_disagreement`).
271
+
272
+ ### Failure modes and fail-closed behavior
273
+
274
+ When calibration cannot be established safely — closed market or weekend
275
+ (`no_copied_ticks`), price disagreement or a non-half-hour delta
276
+ (`no_matching_event`), a single matched event only (`insufficient_agreement`),
277
+ missing live data (`no_live_tick`), or an unrealistic delta
278
+ (`implausible_offset`) — snapshots fail closed: `clock_status` is
279
+ `"uncalibrated"`, `time_utc` and `server_clock_offset_seconds` are `None`,
280
+ and the raw fields remain available. A raw timestamp is never silently
281
+ treated as UTC. `normalizer.calibration` exposes the full
282
+ `TickClockCalibration` diagnostics: status, offset, distinct sample count,
283
+ evidence symbols, and last calibration time (`to_dict()` for serialization).
284
+
285
+ ### Caching and revalidation
286
+
287
+ Calibration is a property of the broker server clock, so it is cached on the
288
+ normalizer (keep one instance per MT5 connection/account) and reused across
289
+ symbols and calls. A cached offset is recomputed in three cases:
290
+
291
+ - After `max_calibration_age_seconds` (default 6 hours, bounding
292
+ DST-transition staleness), unconditionally.
293
+ - Immediately when a normalized timestamp lands more than two minutes in the
294
+ future — evidence that the broker offset grew, e.g. a UTC+2 to UTC+3
295
+ transition.
296
+ - Periodically, at most once per `revalidation_interval_seconds` (default 5
297
+ minutes), a single confirming sample is taken against the still-cached
298
+ offset; a disagreement forces full recalibration. This is what catches a
299
+ broker offset _decrease_ (e.g. UTC+3 to UTC+2): the resulting normalized
300
+ time looks stale rather than future, which a future-skew check alone
301
+ cannot distinguish from ordinary quiet-market staleness.
302
+
303
+ If recalibration still cannot validate the timestamp, the snapshot fails
304
+ closed. Failed calibrations are recorded for diagnostics but never reused;
305
+ retries are bounded to at most once per `failed_calibration_retry_seconds`
306
+ (default 30 seconds) rather than on every call, so a closed or illiquid
307
+ market does not trigger a full resample on every snapshot request.
308
+
309
+ A genuinely stale tick (weekend, illiquid symbol) under a valid calibration
310
+ normalizes to its true **past** UTC instant — staleness is preserved, never
311
+ corrected to the present.
312
+
313
+ mt5cli owns MT5 timestamp normalization only. Freshness thresholds,
314
+ entry-blocking rules, and other strategy policy belong in downstream
315
+ applications.
316
+
226
317
  ## Migration from application-local helpers
227
318
 
228
319
  | Application-local concern | mt5cli replacement |
@@ -234,7 +325,7 @@ No offset is applied automatically by history retrieval helpers.
234
325
  | Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
235
326
  | Local closed-bar fetch from a session | `fetch_latest_closed_rates()` or `fetch_latest_closed_rates_indexed()` |
236
327
  | Local recent deal history fetch from a session | `client.recent_history_deals()` |
237
- | Local broker server clock offset measurement | `estimate_server_clock_offset_seconds()` |
328
+ | Local broker server clock offset measurement | `TickClockNormalizer` |
238
329
  | Local SL/TP price derivation | `determine_order_limits()` |
239
330
  | Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
240
331
 
@@ -40,8 +40,11 @@ from .marketdata import (
40
40
  )
41
41
  from .observability import update_observability, update_observability_with_config
42
42
  from .trading import (
43
+ CalibrationStatus,
44
+ ClockStatus,
43
45
  ExecutionStatus,
44
46
  MarginVolume,
47
+ NormalizedTickSnapshot,
45
48
  OrderExecutionResult,
46
49
  OrderFillingMode,
47
50
  OrderLimits,
@@ -49,6 +52,8 @@ from .trading import (
49
52
  OrderTimeMode,
50
53
  PositionSide,
51
54
  ProjectionMode,
55
+ TickClockCalibration,
56
+ TickClockNormalizer,
52
57
  calculate_account_projected_margin_ratio,
53
58
  calculate_margin_and_volume,
54
59
  calculate_new_position_margin_ratio,
@@ -65,7 +70,6 @@ from .trading import (
65
70
  determine_order_limits,
66
71
  ensure_symbol_selected,
67
72
  estimate_order_margin,
68
- estimate_server_clock_offset_seconds,
69
73
  extract_tick_price,
70
74
  fetch_latest_closed_rates_indexed,
71
75
  get_account_snapshot,
@@ -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,126 +45,6 @@ if TYPE_CHECKING:
46
45
 
47
46
  logger = logging.getLogger(__name__)
48
47
 
49
- # ---------------------------------------------------------------------------
50
- # Click parameter types
51
- # ---------------------------------------------------------------------------
52
-
53
-
54
- class _DateTimeType(click.ParamType[datetime]):
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 = None,
63
- ctx: click.Context | None = 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[int]):
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 = None,
92
- ctx: click.Context | None = 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[int]):
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 = None,
119
- ctx: click.Context | None = 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[dict[str, Any]]):
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 = None,
146
- ctx: click.Context | None = 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
-
169
48
 
170
49
  def _parse_datetime_parameter(value: str) -> datetime:
171
50
  try:
@@ -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
@@ -112,10 +112,15 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
112
112
  "OrderTimeMode",
113
113
  "PositionSide",
114
114
  "ProjectionMode",
115
+ "CalibrationStatus",
116
+ "ClockStatus",
115
117
  "ExecutionStatus",
116
118
  "MarginVolume",
119
+ "NormalizedTickSnapshot",
117
120
  "OrderExecutionResult",
118
121
  "OrderLimits",
122
+ "TickClockCalibration",
123
+ "TickClockNormalizer",
119
124
  "RateTarget",
120
125
  "ThrottledHistoryUpdater",
121
126
  "build_config",
@@ -141,7 +146,6 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
141
146
  "drop_forming_rate_bar",
142
147
  "ensure_symbol_selected",
143
148
  "estimate_order_margin",
144
- "estimate_server_clock_offset_seconds",
145
149
  "extract_tick_price",
146
150
  "fetch_latest_closed_rates",
147
151
  "fetch_latest_closed_rates_indexed",
@@ -51,6 +51,10 @@ DEFAULT_HISTORY_DATASETS: frozenset[Dataset] = frozenset({
51
51
  Dataset.history_orders,
52
52
  Dataset.history_deals,
53
53
  })
54
+ _EXPECTED_EMPTY_HISTORY_DATASETS: frozenset[Dataset] = frozenset({
55
+ Dataset.history_orders,
56
+ Dataset.history_deals,
57
+ })
54
58
 
55
59
  _HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
56
60
  Dataset.rates: DEDUP_KEYS[DataKind.rates],
@@ -1341,11 +1345,20 @@ def get_incremental_start_datetime(
1341
1345
  return starts[symbol, timeframe]
1342
1346
 
1343
1347
 
1348
+ def _empty_dataset_log_level(dataset: Dataset) -> int:
1349
+ """Return the log level for a schema-less dataset result."""
1350
+ if dataset in _EXPECTED_EMPTY_HISTORY_DATASETS:
1351
+ return logging.INFO
1352
+ return logging.WARNING
1353
+
1354
+
1344
1355
  def append_dataframe(
1345
1356
  conn: sqlite3.Connection,
1346
1357
  frame: pd.DataFrame,
1347
1358
  table_name: str,
1348
1359
  if_exists: IfExists,
1360
+ *,
1361
+ empty_log_level: int = logging.WARNING,
1349
1362
  ) -> bool:
1350
1363
  """Append a DataFrame to SQLite when it has a schema.
1351
1364
 
@@ -1353,7 +1366,11 @@ def append_dataframe(
1353
1366
  True if a table was written, False if the frame had no columns.
1354
1367
  """
1355
1368
  if len(frame.columns) == 0:
1356
- logger.warning("Skipping %s: dataset returned no columns", table_name)
1369
+ logger.log(
1370
+ empty_log_level,
1371
+ "Skipping %s: dataset returned no columns",
1372
+ table_name,
1373
+ )
1357
1374
  return False
1358
1375
  writable = _canonicalize_sqlite_time_columns(frame)
1359
1376
  writable.to_sql( # type: ignore[reportUnknownMemberType]
@@ -1409,7 +1426,13 @@ def write_streamed_frame(
1409
1426
  True if the dataset table exists after this write attempt.
1410
1427
  """
1411
1428
  write_mode = IfExists.APPEND if table_exists else if_exists
1412
- if append_dataframe(conn, frame, dataset.table_name, write_mode):
1429
+ if append_dataframe(
1430
+ conn,
1431
+ frame,
1432
+ dataset.table_name,
1433
+ write_mode,
1434
+ empty_log_level=_empty_dataset_log_level(dataset),
1435
+ ):
1413
1436
  record_written_columns(written_columns, dataset, frame)
1414
1437
  return True
1415
1438
  return table_exists
@@ -1751,7 +1774,8 @@ def _stream_symbol_frames(
1751
1774
  for sym in symbols:
1752
1775
  frame = fetch_frame(sym)
1753
1776
  if len(frame.columns) == 0:
1754
- logger.warning(
1777
+ logger.log(
1778
+ _empty_dataset_log_level(dataset),
1755
1779
  "Skipping %s for symbol=%s: dataset returned no columns",
1756
1780
  dataset.table_name,
1757
1781
  sym,
@@ -2243,8 +2267,8 @@ def _finalize_incremental_writes(
2243
2267
  Dataset.history_deals not in written_columns
2244
2268
  and Dataset.history_deals not in written_tables
2245
2269
  ):
2246
- logger.warning(
2247
- "with_views ignored: history_deals table was not available",
2270
+ logger.info(
2271
+ "Skipping history-deal views: no history_deals data was available",
2248
2272
  )
2249
2273
 
2250
2274
 
@@ -2755,11 +2779,7 @@ class ThrottledHistoryUpdater:
2755
2779
  When ``suppress_errors`` is False, recoverable update failures
2756
2780
  propagate to the caller.
2757
2781
 
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
2782
+ """
2763
2783
  if not self.should_update():
2764
2784
  return False
2765
2785
  try:
@@ -2852,9 +2872,13 @@ def collect_history(
2852
2872
  conn,
2853
2873
  written_columns[Dataset.history_deals],
2854
2874
  )
2875
+ elif with_views and Dataset.history_deals in selected:
2876
+ logger.info(
2877
+ "Skipping history-deal views: no history_deals data was collected",
2878
+ )
2855
2879
  elif with_views:
2856
2880
  logger.warning(
2857
- "--with-views ignored: history_deals table was not written",
2881
+ "--with-views requires the history_deals dataset",
2858
2882
  )
2859
2883
  logger.info(
2860
2884
  "Collected %s for %d symbol(s) into %s",