mt5cli 1.3.1__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.1 → mt5cli-1.3.2}/PKG-INFO +1 -1
  2. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/trading.md +10 -8
  3. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/trading.py +41 -30
  4. {mt5cli-1.3.1 → mt5cli-1.3.2}/pyproject.toml +1 -1
  5. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_trading.py +133 -2
  6. {mt5cli-1.3.1 → mt5cli-1.3.2}/uv.lock +1 -1
  7. {mt5cli-1.3.1 → mt5cli-1.3.2}/.agents/skills/local-qa/SKILL.md +0 -0
  8. {mt5cli-1.3.1 → mt5cli-1.3.2}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  9. {mt5cli-1.3.1 → mt5cli-1.3.2}/.agents/skills/mt5cli/SKILL.md +0 -0
  10. {mt5cli-1.3.1 → mt5cli-1.3.2}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  11. {mt5cli-1.3.1 → mt5cli-1.3.2}/.claude/agents/codex.md +0 -0
  12. {mt5cli-1.3.1 → mt5cli-1.3.2}/.claude/settings.json +0 -0
  13. {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/FUNDING.yml +0 -0
  14. {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/dependabot.yml +0 -0
  15. {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/renovate.json +0 -0
  16. {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/workflows/ci.yml +0 -0
  17. {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/workflows/claude.yml +0 -0
  18. {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/workflows/release.yml +0 -0
  19. {mt5cli-1.3.1 → mt5cli-1.3.2}/.gitignore +0 -0
  20. {mt5cli-1.3.1 → mt5cli-1.3.2}/AGENTS.md +0 -0
  21. {mt5cli-1.3.1 → mt5cli-1.3.2}/CLAUDE.md +0 -0
  22. {mt5cli-1.3.1 → mt5cli-1.3.2}/LICENSE +0 -0
  23. {mt5cli-1.3.1 → mt5cli-1.3.2}/README.md +0 -0
  24. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/cli.md +0 -0
  25. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/client.md +0 -0
  26. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/converters.md +0 -0
  27. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/exceptions.md +0 -0
  28. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/grafana.md +0 -0
  29. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/history.md +0 -0
  30. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/index.md +0 -0
  31. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/marketdata.md +0 -0
  32. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/observability.md +0 -0
  33. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/public-contract.md +0 -0
  34. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/schemas.md +0 -0
  35. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/telemetry.md +0 -0
  36. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/utils.md +0 -0
  37. {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/index.md +0 -0
  38. {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/README.md +0 -0
  39. {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/compose.yml +0 -0
  40. {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/dashboards/mt5cli-market.json +0 -0
  41. {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
  42. {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
  43. {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
  44. {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
  45. {mt5cli-1.3.1 → mt5cli-1.3.2}/mkdocs.yml +0 -0
  46. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/__init__.py +0 -0
  47. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/__main__.py +0 -0
  48. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/cli.py +0 -0
  49. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/client.py +0 -0
  50. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/contract.py +0 -0
  51. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/converters.py +0 -0
  52. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/exceptions.py +0 -0
  53. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/grafana.py +0 -0
  54. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/history.py +0 -0
  55. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/marketdata.py +0 -0
  56. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/observability.py +0 -0
  57. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/retry.py +0 -0
  58. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/schemas.py +0 -0
  59. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/telemetry.py +0 -0
  60. {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/utils.py +0 -0
  61. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/__init__.py +0 -0
  62. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/conftest.py +0 -0
  63. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_cli.py +0 -0
  64. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_client.py +0 -0
  65. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_contracts.py +0 -0
  66. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_examples.py +0 -0
  67. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_grafana.py +0 -0
  68. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_history.py +0 -0
  69. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_marketdata.py +0 -0
  70. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_observability.py +0 -0
  71. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_packaging.py +0 -0
  72. {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_telemetry.py +0 -0
  73. {mt5cli-1.3.1 → 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.1
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
 
@@ -304,11 +305,12 @@ symbols and calls. A cached offset is recomputed in three cases:
304
305
  future — evidence that the broker offset grew, e.g. a UTC+2 to UTC+3
305
306
  transition.
306
307
  - Periodically, at most once per `revalidation_interval_seconds` (default 5
307
- minutes), a single confirming sample is taken against the still-cached
308
- offset; a disagreement forces full recalibration. This is what catches a
309
- broker offset _decrease_ (e.g. UTC+3 to UTC+2): the resulting normalized
310
- time looks stale rather than future, which a future-skew check alone
311
- 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.
312
314
 
313
315
  If recalibration still cannot validate the timestamp, the snapshot fails
314
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
 
@@ -1122,7 +1128,7 @@ def _matching_ticks_newest_first(
1122
1128
  live-vs-copied comparison is in flight, so every recent copied row is
1123
1129
  searched for the same market event as ``live`` instead of assuming the
1124
1130
  newest copied row is the relevant one. A newer row can also
1125
- coincidentally share ``live``'s price/volume fields without being the
1131
+ coincidentally share ``live``'s price fields without being the
1126
1132
  same event, so every match is returned for offset-plausibility
1127
1133
  evaluation rather than only the newest.
1128
1134
 
@@ -1144,7 +1150,7 @@ def _sample_clock_offset(
1144
1150
  """Compare one live tick against recent copied UTC ticks.
1145
1151
 
1146
1152
  Every field-matching copied tick is evaluated, newest first, so a newer
1147
- row that coincidentally shares the live tick's price/volume fields
1153
+ row that coincidentally shares the live tick's price fields
1148
1154
  cannot shadow a plausible offset from an older exact match; recency
1149
1155
  only breaks ties among candidates that are themselves plausible.
1150
1156
 
@@ -1258,12 +1264,13 @@ class TickClockNormalizer:
1258
1264
  is recomputed unconditionally (bounds DST-transition
1259
1265
  staleness).
1260
1266
  revalidation_interval_seconds: Minimum time between opportunistic
1261
- single-sample checks of an otherwise still-fresh cached
1262
- calibration. A disagreeing sample forces full recalibration,
1263
- which catches an offset decrease (for example a UTC+3 to
1264
- UTC+2 transition) well before ``max_calibration_age_seconds``
1265
- would; an inconclusive or agreeing sample keeps the cached
1266
- 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.
1267
1274
  failed_calibration_retry_seconds: Minimum time between full
1268
1275
  recalibration attempts after a failed calibration, so a
1269
1276
  closed or illiquid market does not retry on every call.
@@ -1451,36 +1458,40 @@ class TickClockNormalizer:
1451
1458
  *,
1452
1459
  now_epoch: float,
1453
1460
  ) -> TickClockCalibration | None:
1454
- """Confirm or replace a still-fresh cached calibration with one sample.
1461
+ """Confirm or replace a still-fresh cached calibration with live symbols.
1455
1462
 
1456
1463
  A future-skew check alone never catches a broker offset *decrease*
1457
1464
  (e.g. a UTC+3 to UTC+2 transition): the resulting normalized time
1458
1465
  looks stale rather than future, and staleness is also the expected
1459
- symptom of a quiet market. This periodic single-sample check is
1460
- symmetric evidence that also detects that case. An inconclusive
1461
- sample (closed market, no matching event) leaves the cached
1462
- 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.
1463
1470
 
1464
1471
  Returns:
1465
1472
  A freshly recalibrated :class:`TickClockCalibration` when the
1466
1473
  fresh sample disagreed with ``cached``, or ``None`` when the
1467
1474
  cached calibration should be kept as-is.
1468
1475
  """
1469
- sample = _sample_clock_offset(
1470
- self._client,
1471
- symbol,
1472
- window_seconds=self._copied_window_seconds,
1473
- )
1474
1476
  self._last_attempt_at = now_epoch
1475
- if (
1476
- sample.offset_seconds is None
1477
- or sample.offset_seconds == cached.offset_seconds
1478
- ):
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:
1479
1489
  return None
1490
+ candidate, sample = changed_sample
1480
1491
  _logger.warning(
1481
1492
  "MT5 server clock offset for %s appears to have changed from"
1482
1493
  " %.0f to %.0f seconds; recalibrating.",
1483
- symbol,
1494
+ candidate,
1484
1495
  cached.offset_seconds,
1485
1496
  sample.offset_seconds,
1486
1497
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "1.3.1"
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"}]
@@ -4795,6 +4795,88 @@ class TestTickClockNormalizer:
4795
4795
  _assert_close(calibration.calibrated_at, _CLOCK_NOW_EPOCH)
4796
4796
  assert client.copy_ticks_range.call_count == 3
4797
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
+
4798
4880
  def test_offset_decrease_is_caught_by_periodic_revalidation(
4799
4881
  self,
4800
4882
  mocker: MockerFixture,
@@ -4913,14 +4995,12 @@ class TestTickClockNormalizer:
4913
4995
  [
4914
4996
  ({"bid": None, "ask": None, "last": None}, {}),
4915
4997
  ({"bid": 0.0, "ask": None, "last": None}, {"bid": 0.0}),
4916
- ({"volume": 6}, {}),
4917
4998
  ({"bid": "1.1", "ask": None, "last": None}, {}),
4918
4999
  ({"bid": float("nan"), "ask": None, "last": None}, {}),
4919
5000
  ],
4920
5001
  ids=[
4921
5002
  "volume-only",
4922
5003
  "zero-price-only",
4923
- "volume-mismatch",
4924
5004
  "string-price",
4925
5005
  "nan-price",
4926
5006
  ],
@@ -4948,6 +5028,57 @@ class TestTickClockNormalizer:
4948
5028
 
4949
5029
  assert normalizer.calibrate().status == "no_matching_event"
4950
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
+
4951
5082
  def test_copied_time_msc_datetime_and_time_fallbacks(
4952
5083
  self,
4953
5084
  mocker: MockerFixture,
@@ -506,7 +506,7 @@ wheels = [
506
506
 
507
507
  [[package]]
508
508
  name = "mt5cli"
509
- version = "1.3.1"
509
+ version = "1.3.2"
510
510
  source = { editable = "." }
511
511
  dependencies = [
512
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