mt5cli 1.3.0__tar.gz → 1.3.2__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.3.0 → mt5cli-1.3.2}/PKG-INFO +1 -1
  2. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/trading.md +20 -8
  3. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/trading.py +55 -33
  4. {mt5cli-1.3.0 → mt5cli-1.3.2}/pyproject.toml +3 -1
  5. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_trading.py +240 -4
  6. {mt5cli-1.3.0 → mt5cli-1.3.2}/uv.lock +8 -1
  7. {mt5cli-1.3.0 → mt5cli-1.3.2}/.agents/skills/local-qa/SKILL.md +0 -0
  8. {mt5cli-1.3.0 → mt5cli-1.3.2}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  9. {mt5cli-1.3.0 → mt5cli-1.3.2}/.agents/skills/mt5cli/SKILL.md +0 -0
  10. {mt5cli-1.3.0 → mt5cli-1.3.2}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  11. {mt5cli-1.3.0 → mt5cli-1.3.2}/.claude/agents/codex.md +0 -0
  12. {mt5cli-1.3.0 → mt5cli-1.3.2}/.claude/settings.json +0 -0
  13. {mt5cli-1.3.0 → mt5cli-1.3.2}/.github/FUNDING.yml +0 -0
  14. {mt5cli-1.3.0 → mt5cli-1.3.2}/.github/dependabot.yml +0 -0
  15. {mt5cli-1.3.0 → mt5cli-1.3.2}/.github/renovate.json +0 -0
  16. {mt5cli-1.3.0 → mt5cli-1.3.2}/.github/workflows/ci.yml +0 -0
  17. {mt5cli-1.3.0 → mt5cli-1.3.2}/.github/workflows/claude.yml +0 -0
  18. {mt5cli-1.3.0 → mt5cli-1.3.2}/.github/workflows/release.yml +0 -0
  19. {mt5cli-1.3.0 → mt5cli-1.3.2}/.gitignore +0 -0
  20. {mt5cli-1.3.0 → mt5cli-1.3.2}/AGENTS.md +0 -0
  21. {mt5cli-1.3.0 → mt5cli-1.3.2}/CLAUDE.md +0 -0
  22. {mt5cli-1.3.0 → mt5cli-1.3.2}/LICENSE +0 -0
  23. {mt5cli-1.3.0 → mt5cli-1.3.2}/README.md +0 -0
  24. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/cli.md +0 -0
  25. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/client.md +0 -0
  26. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/converters.md +0 -0
  27. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/exceptions.md +0 -0
  28. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/grafana.md +0 -0
  29. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/history.md +0 -0
  30. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/index.md +0 -0
  31. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/marketdata.md +0 -0
  32. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/observability.md +0 -0
  33. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/public-contract.md +0 -0
  34. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/schemas.md +0 -0
  35. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/telemetry.md +0 -0
  36. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/api/utils.md +0 -0
  37. {mt5cli-1.3.0 → mt5cli-1.3.2}/docs/index.md +0 -0
  38. {mt5cli-1.3.0 → mt5cli-1.3.2}/examples/grafana/README.md +0 -0
  39. {mt5cli-1.3.0 → mt5cli-1.3.2}/examples/grafana/compose.yml +0 -0
  40. {mt5cli-1.3.0 → mt5cli-1.3.2}/examples/grafana/dashboards/mt5cli-market.json +0 -0
  41. {mt5cli-1.3.0 → mt5cli-1.3.2}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
  42. {mt5cli-1.3.0 → mt5cli-1.3.2}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
  43. {mt5cli-1.3.0 → mt5cli-1.3.2}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
  44. {mt5cli-1.3.0 → mt5cli-1.3.2}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
  45. {mt5cli-1.3.0 → mt5cli-1.3.2}/mkdocs.yml +0 -0
  46. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/__init__.py +0 -0
  47. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/__main__.py +0 -0
  48. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/cli.py +0 -0
  49. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/client.py +0 -0
  50. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/contract.py +0 -0
  51. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/converters.py +0 -0
  52. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/exceptions.py +0 -0
  53. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/grafana.py +0 -0
  54. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/history.py +0 -0
  55. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/marketdata.py +0 -0
  56. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/observability.py +0 -0
  57. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/retry.py +0 -0
  58. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/schemas.py +0 -0
  59. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/telemetry.py +0 -0
  60. {mt5cli-1.3.0 → mt5cli-1.3.2}/mt5cli/utils.py +0 -0
  61. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/__init__.py +0 -0
  62. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/conftest.py +0 -0
  63. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_cli.py +0 -0
  64. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_client.py +0 -0
  65. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_contracts.py +0 -0
  66. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_examples.py +0 -0
  67. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_grafana.py +0 -0
  68. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_history.py +0 -0
  69. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_marketdata.py +0 -0
  70. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_observability.py +0 -0
  71. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_packaging.py +0 -0
  72. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_telemetry.py +0 -0
  73. {mt5cli-1.3.0 → mt5cli-1.3.2}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 1.3.0
3
+ Version: 1.3.2
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>
@@ -255,9 +255,10 @@ The offset is never inferred from `latest_tick_time - now` alone. Each
255
255
  calibration sample fetches the live `symbol_info_tick()` value **and** recent
256
256
  UTC-labeled `copy_ticks_range()` data, then searches every recent copied row
257
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.
258
+ positive `bid`/`ask`/`last` fields. Volume is not used as event identity
259
+ because latest-tick and copied-tick views can represent OTC quote volume
260
+ differently. The newest matching row sets the resulting offset, which must
261
+ align to a 30-minute increment within the realistic UTC-14..UTC+14 range.
261
262
  `time_msc` is preferred over second-resolution `time` on both sides when
262
263
  available.
263
264
 
@@ -269,6 +270,16 @@ updating symbol (paced by `sample_interval_seconds`) or across several
269
270
  symbols passed to the constructor. Any two matched samples that disagree
270
271
  abort the calibration (`offset_disagreement`).
271
272
 
273
+ The `copy_ticks_range()` query backing each sample extends its upper bound
274
+ `_MAX_FUTURE_SKEW_SECONDS` (2 minutes) past the host clock's current time, in
275
+ addition to covering the full trailing `copied_window_seconds`. A live tick's
276
+ true UTC event is not always at or before the host clock — feed/broker clock
277
+ skew can put it a few seconds ahead — and without that tolerance the matching
278
+ copied row would never be queried, so calibration would always report
279
+ `no_matching_event`. The tolerance is bounded, not open-ended: an event
280
+ further ahead than `_MAX_FUTURE_SKEW_SECONDS` is still excluded from the
281
+ query and cannot be matched.
282
+
272
283
  ### Failure modes and fail-closed behavior
273
284
 
274
285
  When calibration cannot be established safely — closed market or weekend
@@ -294,11 +305,12 @@ symbols and calls. A cached offset is recomputed in three cases:
294
305
  future — evidence that the broker offset grew, e.g. a UTC+2 to UTC+3
295
306
  transition.
296
307
  - 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.
308
+ minutes), configured symbols are sampled until one confirms the
309
+ still-cached offset. Closed or inconclusive symbols are skipped; a detected
310
+ disagreement forces full recalibration. This is what catches a broker
311
+ offset _decrease_ (e.g. UTC+3 to UTC+2): the resulting normalized time
312
+ looks stale rather than future, which a future-skew check alone cannot
313
+ distinguish from ordinary quiet-market staleness.
302
314
 
303
315
  If recalibration still cannot validate the timestamp, the snapshot fails
304
316
  closed. Failed calibrations are recorded for diagnostics but never reused;
@@ -1028,18 +1028,25 @@ def _match_value(value: object) -> float | None:
1028
1028
  def _ticks_match(live: Mapping[str, object], copied: Mapping[str, object]) -> bool:
1029
1029
  """Decide whether a live tick and a copied tick are the same market event.
1030
1030
 
1031
- Every field present on both sides (``bid``, ``ask``, ``last``,
1032
- ``volume``) must agree, and at least one positive price field must supply
1033
- evidence; volume agreement or all-zero prices alone are insufficient.
1031
+ Every positive price field present on both sides must agree, and at least
1032
+ one such field must supply evidence. ``volume`` is deliberately excluded:
1033
+ MT5's latest-tick snapshot and copied-tick history can expose different
1034
+ volume representations for the same quote event, especially for OTC
1035
+ instruments whose meaningful fields are Bid and Ask.
1034
1036
 
1035
1037
  Returns:
1036
1038
  True when the two ticks plausibly describe the same market event.
1037
1039
  """
1038
1040
  matched_price_field = False
1039
- for field in (*_TICK_MATCH_PRICE_FIELDS, "volume"):
1041
+ for field in _TICK_MATCH_PRICE_FIELDS:
1040
1042
  live_value = _match_value(live.get(field))
1041
1043
  copied_value = _match_value(copied.get(field))
1042
- if live_value is None or copied_value is None:
1044
+ if (
1045
+ live_value is None
1046
+ or copied_value is None
1047
+ or live_value <= 0
1048
+ or copied_value <= 0
1049
+ ):
1043
1050
  continue
1044
1051
  tolerance = _PRICE_MATCH_RELATIVE_TOLERANCE * max(
1045
1052
  1.0,
@@ -1048,8 +1055,7 @@ def _ticks_match(live: Mapping[str, object], copied: Mapping[str, object]) -> bo
1048
1055
  )
1049
1056
  if abs(live_value - copied_value) > tolerance:
1050
1057
  return False
1051
- if field in _TICK_MATCH_PRICE_FIELDS and live_value > 0:
1052
- matched_price_field = True
1058
+ matched_price_field = True
1053
1059
  return matched_price_field
1054
1060
 
1055
1061
 
@@ -1060,7 +1066,14 @@ def _recent_copied_ticks(
1060
1066
  window_seconds: float,
1061
1067
  now_epoch: float,
1062
1068
  ) -> pd.DataFrame | None:
1063
- """Fetch copied ticks for the trailing UTC window ending now.
1069
+ """Fetch copied ticks for the trailing UTC window ending just past now.
1070
+
1071
+ The upper query bound extends ``_MAX_FUTURE_SKEW_SECONDS`` past
1072
+ ``now_epoch`` so a live tick whose true UTC event is slightly ahead of
1073
+ the host clock (feed/server clock skew, not a broker wall-clock label)
1074
+ still falls inside the queried range and can be matched; that tolerance
1075
+ matches the future-skew bound already enforced on normalized snapshots,
1076
+ so it cannot admit an event further ahead than the supported policy.
1064
1077
 
1065
1078
  Returns:
1066
1079
  The copied-tick DataFrame, or ``None`` when retrieval fails or the
@@ -1069,13 +1082,14 @@ def _recent_copied_ticks(
1069
1082
  flags = getattr(getattr(client, "mt5", None), "COPY_TICKS_ALL", None)
1070
1083
  if not isinstance(flags, int):
1071
1084
  flags = _COPY_TICKS_ALL_FALLBACK
1085
+ query_end_epoch = now_epoch + _MAX_FUTURE_SKEW_SECONDS
1072
1086
  try:
1073
1087
  frame = cast(
1074
1088
  "object",
1075
1089
  client.copy_ticks_range(
1076
1090
  symbol,
1077
1091
  datetime.fromtimestamp(now_epoch - window_seconds, tz=UTC),
1078
- datetime.fromtimestamp(now_epoch, tz=UTC),
1092
+ datetime.fromtimestamp(query_end_epoch, tz=UTC),
1079
1093
  flags,
1080
1094
  ),
1081
1095
  )
@@ -1114,7 +1128,7 @@ def _matching_ticks_newest_first(
1114
1128
  live-vs-copied comparison is in flight, so every recent copied row is
1115
1129
  searched for the same market event as ``live`` instead of assuming the
1116
1130
  newest copied row is the relevant one. A newer row can also
1117
- coincidentally share ``live``'s price/volume fields without being the
1131
+ coincidentally share ``live``'s price fields without being the
1118
1132
  same event, so every match is returned for offset-plausibility
1119
1133
  evaluation rather than only the newest.
1120
1134
 
@@ -1136,7 +1150,7 @@ def _sample_clock_offset(
1136
1150
  """Compare one live tick against recent copied UTC ticks.
1137
1151
 
1138
1152
  Every field-matching copied tick is evaluated, newest first, so a newer
1139
- row that coincidentally shares the live tick's price/volume fields
1153
+ row that coincidentally shares the live tick's price fields
1140
1154
  cannot shadow a plausible offset from an older exact match; recency
1141
1155
  only breaks ties among candidates that are themselves plausible.
1142
1156
 
@@ -1242,17 +1256,21 @@ class TickClockNormalizer:
1242
1256
  sample_interval_seconds: Pause between consecutive samples so an
1243
1257
  actively updating symbol can produce distinct tick events.
1244
1258
  copied_window_seconds: Trailing UTC window queried from
1245
- ``copy_ticks_range`` per sample.
1259
+ ``copy_ticks_range`` per sample, ending
1260
+ ``_MAX_FUTURE_SKEW_SECONDS`` past the host clock's current
1261
+ time so a live tick slightly ahead of the host clock (feed
1262
+ clock skew) remains matchable.
1246
1263
  max_calibration_age_seconds: Age after which a cached calibration
1247
1264
  is recomputed unconditionally (bounds DST-transition
1248
1265
  staleness).
1249
1266
  revalidation_interval_seconds: Minimum time between opportunistic
1250
- single-sample checks of an otherwise still-fresh cached
1251
- calibration. A disagreeing sample forces full recalibration,
1252
- which catches an offset decrease (for example a UTC+3 to
1253
- UTC+2 transition) well before ``max_calibration_age_seconds``
1254
- would; an inconclusive or agreeing sample keeps the cached
1255
- calibration.
1267
+ checks of an otherwise still-fresh cached calibration.
1268
+ Configured symbols are sampled until one confirms the cache;
1269
+ a disagreeing sample forces full recalibration only when none
1270
+ confirms it. This catches an offset decrease (for example a
1271
+ UTC+3 to UTC+2 transition) well before
1272
+ ``max_calibration_age_seconds`` would, while a closed symbol
1273
+ cannot hide fresh evidence from another active symbol.
1256
1274
  failed_calibration_retry_seconds: Minimum time between full
1257
1275
  recalibration attempts after a failed calibration, so a
1258
1276
  closed or illiquid market does not retry on every call.
@@ -1440,36 +1458,40 @@ class TickClockNormalizer:
1440
1458
  *,
1441
1459
  now_epoch: float,
1442
1460
  ) -> TickClockCalibration | None:
1443
- """Confirm or replace a still-fresh cached calibration with one sample.
1461
+ """Confirm or replace a still-fresh cached calibration with live symbols.
1444
1462
 
1445
1463
  A future-skew check alone never catches a broker offset *decrease*
1446
1464
  (e.g. a UTC+3 to UTC+2 transition): the resulting normalized time
1447
1465
  looks stale rather than future, and staleness is also the expected
1448
- symptom of a quiet market. This periodic single-sample check is
1449
- symmetric evidence that also detects that case. An inconclusive
1450
- sample (closed market, no matching event) leaves the cached
1451
- calibration in place rather than discarding a still-working offset.
1466
+ symptom of a quiet market. This periodic check probes the configured
1467
+ symbols until one confirms the cached offset. Inconclusive samples
1468
+ from closed markets are skipped, so one regional symbol cannot hide
1469
+ fresh evidence from another active symbol.
1452
1470
 
1453
1471
  Returns:
1454
1472
  A freshly recalibrated :class:`TickClockCalibration` when the
1455
1473
  fresh sample disagreed with ``cached``, or ``None`` when the
1456
1474
  cached calibration should be kept as-is.
1457
1475
  """
1458
- sample = _sample_clock_offset(
1459
- self._client,
1460
- symbol,
1461
- window_seconds=self._copied_window_seconds,
1462
- )
1463
1476
  self._last_attempt_at = now_epoch
1464
- if (
1465
- sample.offset_seconds is None
1466
- or sample.offset_seconds == cached.offset_seconds
1467
- ):
1477
+ changed_sample: tuple[str, _OffsetSample] | None = None
1478
+ for candidate in self._symbols or (symbol,):
1479
+ sample = _sample_clock_offset(
1480
+ self._client,
1481
+ candidate,
1482
+ window_seconds=self._copied_window_seconds,
1483
+ )
1484
+ if sample.offset_seconds == cached.offset_seconds:
1485
+ return None
1486
+ if sample.offset_seconds is not None and changed_sample is None:
1487
+ changed_sample = (candidate, sample)
1488
+ if changed_sample is None:
1468
1489
  return None
1490
+ candidate, sample = changed_sample
1469
1491
  _logger.warning(
1470
1492
  "MT5 server clock offset for %s appears to have changed from"
1471
1493
  " %.0f to %.0f seconds; recalibrating.",
1472
- symbol,
1494
+ candidate,
1473
1495
  cached.offset_seconds,
1474
1496
  sample.offset_seconds,
1475
1497
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "1.3.0"
3
+ version = "1.3.2"
4
4
  description = "Generic MT5 data and execution infrastructure for Python applications"
5
5
  authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
6
6
  maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
@@ -40,6 +40,8 @@ Repository = "https://github.com/dceoy/mt5cli.git"
40
40
 
41
41
  [tool.uv]
42
42
  required-environments = ["platform_system == 'Windows'"]
43
+ exclude-newer = "1 week"
44
+ exclude-newer-package = { pdmt5 = false }
43
45
 
44
46
  [dependency-groups]
45
47
  dev = [
@@ -14,12 +14,15 @@ from numpy import int64 as np_int64
14
14
  from pdmt5 import Mt5RuntimeError
15
15
 
16
16
  if TYPE_CHECKING:
17
+ from collections.abc import Callable
18
+
17
19
  from pytest_mock import MockerFixture
18
20
 
19
21
  from mt5cli import trading
20
22
  from mt5cli.client import MT5Client
21
23
  from mt5cli.exceptions import Mt5OperationError
22
24
  from mt5cli.trading import (
25
+ _MAX_FUTURE_SKEW_SECONDS, # type: ignore[reportPrivateUsage]
23
26
  MarginVolume,
24
27
  OrderExecutionResult,
25
28
  OrderLimits,
@@ -4145,6 +4148,31 @@ def _clock_client(
4145
4148
  return client
4146
4149
 
4147
4150
 
4151
+ def _windowed_copy_ticks_range(
4152
+ frame: pd.DataFrame,
4153
+ ) -> Callable[[str, datetime, datetime, int], pd.DataFrame]:
4154
+ """Build a ``copy_ticks_range`` fake that filters rows by query bounds.
4155
+
4156
+ Real MT5 only returns rows within ``[date_from, date_to]``; a plain
4157
+ canned return value would hide a regression where the production code
4158
+ queries too narrow a window, since the mock would return the same rows
4159
+ regardless of the arguments it was called with.
4160
+ """
4161
+
4162
+ def _copy_ticks_range(
4163
+ _symbol: str,
4164
+ date_from: datetime,
4165
+ date_to: datetime,
4166
+ _flags: int,
4167
+ ) -> pd.DataFrame:
4168
+ from_epoch = date_from.timestamp()
4169
+ to_epoch = date_to.timestamp()
4170
+ mask = (frame["time"] >= from_epoch) & (frame["time"] <= to_epoch)
4171
+ return frame[mask].reset_index(drop=True)
4172
+
4173
+ return _copy_ticks_range
4174
+
4175
+
4148
4176
  class TestTickClockNormalizer:
4149
4177
  """Tests for TickClockNormalizer calibration and UTC normalization."""
4150
4178
 
@@ -4192,7 +4220,7 @@ class TestTickClockNormalizer:
4192
4220
  mt5_flag: int | None,
4193
4221
  expected_flag: int,
4194
4222
  ) -> None:
4195
- """Copied ticks are queried over a trailing UTC window ending now."""
4223
+ """Copied ticks are queried over a window trailing and past now."""
4196
4224
  _freeze_clock(mocker)
4197
4225
  event = _CLOCK_NOW_EPOCH - 1
4198
4226
  client = _clock_client(
@@ -4213,10 +4241,87 @@ class TestTickClockNormalizer:
4213
4241
  client.copy_ticks_range.assert_called_once_with(
4214
4242
  "SING30",
4215
4243
  datetime.fromtimestamp(_CLOCK_NOW_EPOCH - 300.0, tz=UTC),
4216
- datetime.fromtimestamp(_CLOCK_NOW_EPOCH, tz=UTC),
4244
+ datetime.fromtimestamp(
4245
+ _CLOCK_NOW_EPOCH + _MAX_FUTURE_SKEW_SECONDS,
4246
+ tz=UTC,
4247
+ ),
4217
4248
  expected_flag,
4218
4249
  )
4219
4250
 
4251
+ def test_live_tick_ahead_of_host_clock_matches_within_widened_window(
4252
+ self,
4253
+ mocker: MockerFixture,
4254
+ ) -> None:
4255
+ """A live tick ~16s ahead of the host clock still calibrates.
4256
+
4257
+ Reproduces the dceoy/mteor#428 production failure: the feed/server
4258
+ clock can run slightly ahead of the host OS clock, so the true UTC
4259
+ event backing a live tick can itself land a few seconds past
4260
+ ``datetime.now(UTC)``. Querying ``copy_ticks_range`` with
4261
+ ``date_to=now`` would exclude that event, leaving only an unrelated
4262
+ older row and forcing ``no_matching_event`` on every sample.
4263
+ """
4264
+ _freeze_clock(mocker)
4265
+ skew = 16.0
4266
+ true_utc_event = _CLOCK_NOW_EPOCH + skew
4267
+ older_unrelated_event = _CLOCK_NOW_EPOCH - 30
4268
+ copied = pd.concat(
4269
+ [
4270
+ _copied_frame(older_unrelated_event, bid=9.9, ask=9.9),
4271
+ _copied_frame(true_utc_event),
4272
+ ],
4273
+ ignore_index=True,
4274
+ )
4275
+ client = _clock_client(
4276
+ [_live_tick(true_utc_event + _UTC_PLUS_3)],
4277
+ pd.DataFrame(),
4278
+ )
4279
+ client.copy_ticks_range.side_effect = _windowed_copy_ticks_range(copied)
4280
+ normalizer = TickClockNormalizer(
4281
+ client,
4282
+ ["SING30"],
4283
+ samples_per_symbol=1,
4284
+ min_agreeing_samples=1,
4285
+ )
4286
+
4287
+ calibration = normalizer.calibrate()
4288
+
4289
+ assert calibration.status == "calibrated"
4290
+ _assert_close(calibration.offset_seconds, _UTC_PLUS_3)
4291
+ assert client.copy_ticks_range.call_count == 1
4292
+
4293
+ def test_copied_event_beyond_future_skew_policy_is_not_matched(
4294
+ self,
4295
+ mocker: MockerFixture,
4296
+ ) -> None:
4297
+ """An event further ahead than the tolerated skew is never queried.
4298
+
4299
+ The widened upper bound is bounded, not open-ended: it matches
4300
+ ``_MAX_FUTURE_SKEW_SECONDS``, the same policy enforced on normalized
4301
+ snapshots, so an event beyond that horizon is excluded from the
4302
+ copied-tick query and can never be silently accepted as a match.
4303
+ """
4304
+ _freeze_clock(mocker)
4305
+ beyond_skew_event = _CLOCK_NOW_EPOCH + _MAX_FUTURE_SKEW_SECONDS + 1.0
4306
+ client = _clock_client(
4307
+ [_live_tick(beyond_skew_event + _UTC_PLUS_3)],
4308
+ pd.DataFrame(),
4309
+ )
4310
+ client.copy_ticks_range.side_effect = _windowed_copy_ticks_range(
4311
+ _copied_frame(beyond_skew_event),
4312
+ )
4313
+ normalizer = TickClockNormalizer(
4314
+ client,
4315
+ ["SING30"],
4316
+ samples_per_symbol=1,
4317
+ min_agreeing_samples=1,
4318
+ )
4319
+
4320
+ calibration = normalizer.calibrate()
4321
+
4322
+ assert calibration.status == "no_copied_ticks"
4323
+ assert calibration.offset_seconds is None
4324
+
4220
4325
  def test_matches_older_copied_row_when_newest_row_disagrees(
4221
4326
  self,
4222
4327
  mocker: MockerFixture,
@@ -4690,6 +4795,88 @@ class TestTickClockNormalizer:
4690
4795
  _assert_close(calibration.calibrated_at, _CLOCK_NOW_EPOCH)
4691
4796
  assert client.copy_ticks_range.call_count == 3
4692
4797
 
4798
+ def test_revalidation_uses_active_configured_symbol_when_one_is_closed(
4799
+ self,
4800
+ mocker: MockerFixture,
4801
+ ) -> None:
4802
+ """One closed symbol cannot hide fresh connection-wide evidence."""
4803
+ mock_dt, _ = _freeze_clock(mocker)
4804
+ event_1 = _CLOCK_NOW_EPOCH - 2
4805
+ event_2 = _CLOCK_NOW_EPOCH - 1
4806
+ later = datetime.fromtimestamp(_CLOCK_NOW_EPOCH + 61.0, tz=UTC)
4807
+ later_event = later.timestamp() - 1
4808
+ client = _clock_client(
4809
+ [
4810
+ _live_tick(event_1 + _UTC_PLUS_3),
4811
+ _live_tick(event_2 + _UTC_PLUS_3, bid=150.0, ask=150.1),
4812
+ _live_tick(event_1 + _UTC_PLUS_3), # closed revalidation sample
4813
+ _live_tick(later_event + _UTC_PLUS_3, bid=150.0, ask=150.1),
4814
+ _live_tick(later_event + _UTC_PLUS_3, bid=150.0, ask=150.1),
4815
+ ],
4816
+ [
4817
+ _copied_frame(event_1),
4818
+ _copied_frame(event_2, bid=150.0, ask=150.1),
4819
+ pd.DataFrame(),
4820
+ _copied_frame(later_event, bid=150.0, ask=150.1),
4821
+ ],
4822
+ )
4823
+ normalizer = TickClockNormalizer(
4824
+ client,
4825
+ ["CLOSED", "ACTIVE"],
4826
+ samples_per_symbol=1,
4827
+ revalidation_interval_seconds=60.0,
4828
+ )
4829
+ assert normalizer.calibrate().status == "calibrated"
4830
+
4831
+ mock_dt.now.return_value = later
4832
+ snapshot = normalizer.get_normalized_tick_snapshot("ACTIVE")
4833
+
4834
+ assert snapshot["clock_status"] == "calibrated"
4835
+ assert snapshot["time_utc"] == datetime.fromtimestamp(later_event, tz=UTC)
4836
+ _assert_close(snapshot["server_clock_offset_seconds"], _UTC_PLUS_3)
4837
+ assert [
4838
+ call.kwargs["symbol"]
4839
+ for call in client.symbol_info_tick_as_dict.call_args_list
4840
+ ] == ["CLOSED", "ACTIVE", "CLOSED", "ACTIVE", "ACTIVE"]
4841
+
4842
+ def test_inconclusive_revalidation_keeps_cached_offset(
4843
+ self,
4844
+ mocker: MockerFixture,
4845
+ ) -> None:
4846
+ """A closed market does not discard a still-valid cached offset."""
4847
+ mock_dt, _ = _freeze_clock(mocker)
4848
+ event_1 = _CLOCK_NOW_EPOCH - 2
4849
+ event_2 = _CLOCK_NOW_EPOCH - 1
4850
+ later = datetime.fromtimestamp(_CLOCK_NOW_EPOCH + 61.0, tz=UTC)
4851
+ client = _clock_client(
4852
+ [
4853
+ _live_tick(event_1 + _UTC_PLUS_3),
4854
+ _live_tick(event_2 + _UTC_PLUS_3),
4855
+ _live_tick(event_2 + _UTC_PLUS_3),
4856
+ _live_tick(event_2 + _UTC_PLUS_3),
4857
+ ],
4858
+ [
4859
+ _copied_frame(event_1),
4860
+ _copied_frame(event_2),
4861
+ pd.DataFrame(),
4862
+ ],
4863
+ )
4864
+ normalizer = TickClockNormalizer(
4865
+ client,
4866
+ ["SING30"],
4867
+ samples_per_symbol=2,
4868
+ revalidation_interval_seconds=60.0,
4869
+ )
4870
+ first = normalizer.calibrate()
4871
+ _assert_close(first.offset_seconds, _UTC_PLUS_3)
4872
+
4873
+ mock_dt.now.return_value = later
4874
+ snapshot = normalizer.get_normalized_tick_snapshot("SING30")
4875
+
4876
+ assert snapshot["clock_status"] == "calibrated"
4877
+ _assert_close(snapshot["server_clock_offset_seconds"], _UTC_PLUS_3)
4878
+ assert client.copy_ticks_range.call_count == 3
4879
+
4693
4880
  def test_offset_decrease_is_caught_by_periodic_revalidation(
4694
4881
  self,
4695
4882
  mocker: MockerFixture,
@@ -4808,14 +4995,12 @@ class TestTickClockNormalizer:
4808
4995
  [
4809
4996
  ({"bid": None, "ask": None, "last": None}, {}),
4810
4997
  ({"bid": 0.0, "ask": None, "last": None}, {"bid": 0.0}),
4811
- ({"volume": 6}, {}),
4812
4998
  ({"bid": "1.1", "ask": None, "last": None}, {}),
4813
4999
  ({"bid": float("nan"), "ask": None, "last": None}, {}),
4814
5000
  ],
4815
5001
  ids=[
4816
5002
  "volume-only",
4817
5003
  "zero-price-only",
4818
- "volume-mismatch",
4819
5004
  "string-price",
4820
5005
  "nan-price",
4821
5006
  ],
@@ -4843,6 +5028,57 @@ class TestTickClockNormalizer:
4843
5028
 
4844
5029
  assert normalizer.calibrate().status == "no_matching_event"
4845
5030
 
5031
+ def test_volume_difference_does_not_hide_matching_quote_event(
5032
+ self,
5033
+ mocker: MockerFixture,
5034
+ ) -> None:
5035
+ """Bid/ask identity remains usable when live and copied volumes differ."""
5036
+ _freeze_clock(mocker)
5037
+ event = _CLOCK_NOW_EPOCH - 1
5038
+ client = _clock_client(
5039
+ [_live_tick(event + _UTC_PLUS_3, volume=6)],
5040
+ _copied_frame(event, volume=5),
5041
+ )
5042
+ normalizer = TickClockNormalizer(
5043
+ client,
5044
+ ["SING30"],
5045
+ samples_per_symbol=1,
5046
+ min_agreeing_samples=1,
5047
+ )
5048
+
5049
+ calibration = normalizer.calibrate()
5050
+
5051
+ assert calibration.status == "calibrated"
5052
+ _assert_close(calibration.offset_seconds, _UTC_PLUS_3)
5053
+
5054
+ def test_half_hour_delayed_feed_is_not_inferred_from_host_clock(
5055
+ self,
5056
+ mocker: MockerFixture,
5057
+ ) -> None:
5058
+ """Advancing stale ticks cannot become fresh through host-time rounding."""
5059
+ _freeze_clock(mocker)
5060
+ delayed_event_1 = _CLOCK_NOW_EPOCH - 1802
5061
+ delayed_event_2 = _CLOCK_NOW_EPOCH - 1801
5062
+ copied = _copied_frame(delayed_event_1, delayed_event_2)
5063
+ client = _clock_client(
5064
+ [
5065
+ _live_tick(delayed_event_1 + _UTC_PLUS_3),
5066
+ _live_tick(delayed_event_2 + _UTC_PLUS_3),
5067
+ ],
5068
+ pd.DataFrame(),
5069
+ )
5070
+ client.copy_ticks_range.side_effect = _windowed_copy_ticks_range(copied)
5071
+ normalizer = TickClockNormalizer(
5072
+ client,
5073
+ ["SING30"],
5074
+ samples_per_symbol=2,
5075
+ )
5076
+
5077
+ calibration = normalizer.calibrate()
5078
+
5079
+ assert calibration.status == "no_copied_ticks"
5080
+ assert calibration.offset_seconds is None
5081
+
4846
5082
  def test_copied_time_msc_datetime_and_time_fallbacks(
4847
5083
  self,
4848
5084
  mocker: MockerFixture,
@@ -10,6 +10,13 @@ required-markers = [
10
10
  "sys_platform == 'win32'",
11
11
  ]
12
12
 
13
+ [options]
14
+ exclude-newer = "2026-07-13T15:37:13.721821839Z"
15
+ exclude-newer-span = "P1W"
16
+
17
+ [options.exclude-newer-package]
18
+ pdmt5 = false
19
+
13
20
  [[package]]
14
21
  name = "annotated-doc"
15
22
  version = "0.0.4"
@@ -499,7 +506,7 @@ wheels = [
499
506
 
500
507
  [[package]]
501
508
  name = "mt5cli"
502
- version = "1.3.0"
509
+ version = "1.3.2"
503
510
  source = { editable = "." }
504
511
  dependencies = [
505
512
  { name = "pandas" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes