mt5cli 1.3.3__tar.gz → 1.4.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.3.3 → mt5cli-1.4.0}/PKG-INFO +1 -1
  2. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/trading.md +54 -8
  3. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/trading.py +315 -43
  4. {mt5cli-1.3.3 → mt5cli-1.4.0}/pyproject.toml +1 -1
  5. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_trading.py +913 -0
  6. {mt5cli-1.3.3 → mt5cli-1.4.0}/uv.lock +2 -2
  7. {mt5cli-1.3.3 → mt5cli-1.4.0}/.agents/skills/local-qa/SKILL.md +0 -0
  8. {mt5cli-1.3.3 → mt5cli-1.4.0}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  9. {mt5cli-1.3.3 → mt5cli-1.4.0}/.agents/skills/mt5cli/SKILL.md +0 -0
  10. {mt5cli-1.3.3 → mt5cli-1.4.0}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  11. {mt5cli-1.3.3 → mt5cli-1.4.0}/.claude/agents/codex.md +0 -0
  12. {mt5cli-1.3.3 → mt5cli-1.4.0}/.claude/settings.json +0 -0
  13. {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/FUNDING.yml +0 -0
  14. {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/dependabot.yml +0 -0
  15. {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/renovate.json +0 -0
  16. {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/workflows/ci.yml +0 -0
  17. {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/workflows/claude.yml +0 -0
  18. {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/workflows/release.yml +0 -0
  19. {mt5cli-1.3.3 → mt5cli-1.4.0}/.gitignore +0 -0
  20. {mt5cli-1.3.3 → mt5cli-1.4.0}/AGENTS.md +0 -0
  21. {mt5cli-1.3.3 → mt5cli-1.4.0}/CLAUDE.md +0 -0
  22. {mt5cli-1.3.3 → mt5cli-1.4.0}/LICENSE +0 -0
  23. {mt5cli-1.3.3 → mt5cli-1.4.0}/README.md +0 -0
  24. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/cli.md +0 -0
  25. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/client.md +0 -0
  26. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/converters.md +0 -0
  27. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/exceptions.md +0 -0
  28. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/grafana.md +0 -0
  29. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/history.md +0 -0
  30. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/index.md +0 -0
  31. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/marketdata.md +0 -0
  32. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/observability.md +0 -0
  33. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/public-contract.md +0 -0
  34. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/schemas.md +0 -0
  35. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/telemetry.md +0 -0
  36. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/utils.md +0 -0
  37. {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/index.md +0 -0
  38. {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/README.md +0 -0
  39. {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/compose.yml +0 -0
  40. {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/dashboards/mt5cli-market.json +0 -0
  41. {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
  42. {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
  43. {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
  44. {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
  45. {mt5cli-1.3.3 → mt5cli-1.4.0}/mkdocs.yml +0 -0
  46. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/__init__.py +0 -0
  47. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/__main__.py +0 -0
  48. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/cli.py +0 -0
  49. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/client.py +0 -0
  50. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/contract.py +0 -0
  51. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/converters.py +0 -0
  52. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/exceptions.py +0 -0
  53. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/grafana.py +0 -0
  54. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/history.py +0 -0
  55. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/marketdata.py +0 -0
  56. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/observability.py +0 -0
  57. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/retry.py +0 -0
  58. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/schemas.py +0 -0
  59. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/telemetry.py +0 -0
  60. {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/utils.py +0 -0
  61. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/__init__.py +0 -0
  62. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/conftest.py +0 -0
  63. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_cli.py +0 -0
  64. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_client.py +0 -0
  65. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_contracts.py +0 -0
  66. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_examples.py +0 -0
  67. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_grafana.py +0 -0
  68. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_history.py +0 -0
  69. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_marketdata.py +0 -0
  70. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_observability.py +0 -0
  71. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_packaging.py +0 -0
  72. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_telemetry.py +0 -0
  73. {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 1.3.3
3
+ Version: 1.4.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>
@@ -264,7 +264,9 @@ A normalized snapshot keeps the raw/UTC distinction explicit:
264
264
 
265
265
  `copy_ticks_range()` is not used for calibration: its documented UTC contract
266
266
  is broker/terminal-dependent and not independently verified here (see
267
- above), so it cannot serve as an out-of-band UTC reference. Instead, the only
267
+ above), so neither the query timestamps nor the returned historical tick
268
+ timestamps can serve as an out-of-band UTC reference, and historical copied
269
+ rows are never used to infer the server offset. Instead, the only
268
270
  independent reference available is **this process's own clock**: every
269
271
  calibration poll
270
272
  fetches one live `symbol_info_tick()` value and stamps it with
@@ -303,7 +305,11 @@ to a plausible bucket (`unstable_offset`), an unrealistic rounded delta
303
305
  missing live data (`no_live_tick`) — snapshots fail closed: `clock_status` is
304
306
  `"uncalibrated"`, `time_utc` and `server_clock_offset_seconds` are `None`,
305
307
  and the raw fields remain available. A raw timestamp is never silently
306
- treated as UTC. `normalizer.calibration` exposes the full
308
+ treated as UTC. The same fail-closed rule applies after a successful
309
+ calibration: once periodic revalidation invalidates the cached offset (see
310
+ below), subsequent snapshots are normalized only by a successful full
311
+ recalibration — never by the refuted cache. `normalizer.calibration`
312
+ exposes the full
307
313
  `TickClockCalibration` diagnostics: status, offset, distinct sample count,
308
314
  evidence symbols, last calibration time, and (for troubleshooting a failed
309
315
  attempt) the most recently considered sample's symbol, raw server-labeled
@@ -323,15 +329,55 @@ symbols and calls. A cached offset is recomputed in three cases:
323
329
  future — evidence that the broker offset grew, e.g. a UTC+2 to UTC+3
324
330
  transition.
325
331
  - Periodically, at most once per `revalidation_interval_seconds` (default 5
326
- minutes), each configured symbol is polled once and compared against that
327
- symbol's last known observation (from the initial calibration or a prior
328
- revalidation) until one confirms the still-cached offset. Closed or
329
- inconclusive symbols are skipped; a detected disagreement forces full
330
- recalibration. This is what catches a broker offset _decrease_ (e.g. UTC+3
331
- to UTC+2): the resulting normalized time looks stale rather than future,
332
+ minutes), through one full revalidation round over every configured
333
+ symbol. This is what catches a broker offset _decrease_ (e.g. UTC+3 to
334
+ UTC+2): the resulting normalized time looks stale rather than future,
332
335
  which a future-skew check alone cannot distinguish from ordinary
333
336
  quiet-market staleness.
334
337
 
338
+ In a revalidation round each configured symbol is polled once and compared
339
+ against that symbol's last known observation (from the initial calibration
340
+ or a prior revalidation), and the whole round is evaluated before any
341
+ decision is made — revalidation never stops at the first confirming symbol.
342
+ A symbol is inconclusive — skipped, neither confirming nor refuting the
343
+ cache — when its fetch is unusable, when it has no prior observation to
344
+ compare against, or when its tick epoch is unchanged since that observation
345
+ (ordinary tick age, e.g. a closed or illiquid symbol). Every other advancing
346
+ sample is evidence the cache must be able to explain:
347
+
348
+ - an accepted offset equal to the cached one confirms the cache, but never
349
+ cancels another symbol's refuting evidence from the same round;
350
+ - an accepted offset that differs from the cached one refutes the cache (a
351
+ clean broker offset change) — unless the cached offset, widened by the
352
+ elapsed time since that symbol's prior observation plus that prior
353
+ observation's own inferred delay under the cached offset, still explains
354
+ the disagreeing raw offset. That case is forgiven once per symbol and held as
355
+ a pending disagreement instead of refuting immediately, since it cannot
356
+ yet be told apart from a maximally delayed tick under the cached offset.
357
+ A pending disagreement escalates to a refutation only if that same
358
+ symbol's next conclusive round rounds to the identical bucket again; any
359
+ inconclusive, differently-bucketed, or unusable round in between clears
360
+ it instead of letting unrelated samples accumulate into false
361
+ corroboration;
362
+ - an advancing sample with no usable offset (`unstable_offset` or
363
+ `implausible_offset`) also refutes the cache, subject to the same
364
+ once-per-symbol forgiveness above — but an unusable sample can never
365
+ itself be held as, or match, a pending disagreement, since it carries no
366
+ specific offset to corroborate. A fresh market event whose raw offset is
367
+ neither reconciled with any plausible 30-minute bucket nor forgiven this
368
+ way is contradictory evidence the cached calibration cannot explain.
369
+
370
+ Any refuting evidence invalidates the cached calibration and forces full
371
+ recalibration; when several symbols refute the cache in one round, the
372
+ reported evidence is deterministic (`implausible_offset` first, then
373
+ `unstable_offset`, then a clean disagreement). The recalibration either
374
+ re-establishes a validated offset or fails closed — a raw tick is never
375
+ normalized with a cached offset that advancing evidence has just refuted. A
376
+ wholly inconclusive round keeps the cache, and a round in which no symbol
377
+ had a comparable baseline does not consume the interval, so the next call
378
+ retries immediately instead of waiting a full
379
+ `revalidation_interval_seconds`.
380
+
335
381
  If recalibration still cannot validate the timestamp, the snapshot fails
336
382
  closed. Failed calibrations are recorded for diagnostics but never reused;
337
383
  retries are bounded to at most once per `failed_calibration_retry_seconds`
@@ -965,7 +965,24 @@ def _numeric_tick_time(value: object) -> float | int | None:
965
965
  _SERVER_CLOCK_OFFSET_ROUNDING_SECONDS = 1800.0
966
966
  _MAX_PLAUSIBLE_SERVER_CLOCK_OFFSET_SECONDS = 14 * 3600.0
967
967
  _OFFSET_RESIDUAL_TOLERANCE_SECONDS = 5.0
968
- _MAX_TICK_AGE_TOLERANCE_SECONDS = 300.0
968
+ # Nearest-bucket rounding can never leave a residual larger than half the
969
+ # bucket width, so widening the tolerance any further than that has no
970
+ # effect. Capping below this bound (a prior, smaller cap matched only the
971
+ # default revalidation interval) let an ordinary tick delayed slightly
972
+ # beyond that interval be misjudged unstable_offset even though its cached
973
+ # offset was still correct.
974
+ _MAX_TICK_AGE_TOLERANCE_SECONDS = _SERVER_CLOCK_OFFSET_ROUNDING_SECONDS / 2
975
+ # A fresh event under a *new* offset one bucket below the cached one is
976
+ # numerically identical to a maximally aged event under the *cached* offset
977
+ # once elapsed time grows past one bucket width -- widening without an upper
978
+ # bound would let an arbitrarily-long gap mask a genuine transition as a
979
+ # merely delayed sample. Capping just below one full bucket keeps every
980
+ # neighboring-bucket disagreement explainable as delay (matching
981
+ # ``_MAX_TICK_AGE_TOLERANCE_SECONDS``'s own bucket-relative reach) while
982
+ # guaranteeing a two-or-more-bucket disagreement can never be suppressed.
983
+ _MAX_CACHED_OFFSET_AGE_TOLERANCE_SECONDS = (
984
+ _SERVER_CLOCK_OFFSET_ROUNDING_SECONDS - _OFFSET_RESIDUAL_TOLERANCE_SECONDS
985
+ )
969
986
  _MAX_FUTURE_SKEW_SECONDS = 120.0
970
987
  _MIN_SAMPLES_PER_SYMBOL = 2
971
988
  _MIN_AGREEING_SAMPLES = 2
@@ -1179,6 +1196,72 @@ def _evaluate_advancement(
1179
1196
  )
1180
1197
 
1181
1198
 
1199
+ def _is_advancing_sample_compatible_with_cached(
1200
+ sample: _OffsetSample,
1201
+ cached_offset_seconds: float,
1202
+ previous: _LiveObservation,
1203
+ ) -> bool:
1204
+ """Whether a nonmatching advancing sample is still explained by a cached offset.
1205
+
1206
+ ``_evaluate_advancement()`` scores a sample against the bucket nearest to
1207
+ its own raw offset, with age slack capped at
1208
+ ``_MAX_TICK_AGE_TOLERANCE_SECONDS`` so an unbounded delay cannot round to
1209
+ the wrong bucket. A delay longer than that cap can push the raw offset
1210
+ past the midpoint to a neighboring bucket — reported as either an
1211
+ unusable sample (``unstable_offset``) or a *usable* sample carrying the
1212
+ wrong offset — even though the event is still perfectly explained by the
1213
+ offset revalidation already has cached. Checking compatibility directly
1214
+ against ``cached_offset_seconds`` has a specific known value to test
1215
+ rather than an unknown bucket to round to, so it can widen by the real
1216
+ elapsed time since ``previous`` -- but only up to
1217
+ ``_MAX_CACHED_OFFSET_AGE_TOLERANCE_SECONDS``: a fresh event under a
1218
+ genuinely new offset one bucket below the cached one produces the exact
1219
+ same raw offset as a maximally aged event under the cached offset once
1220
+ elapsed time exceeds one bucket width, so an uncapped widening could
1221
+ silently mask a real transition as a merely delayed sample after a long
1222
+ enough gap. Capping below one bucket width still forgives any
1223
+ neighboring-bucket disagreement while a disagreement of two or more
1224
+ buckets can never be explained away. Within one bucket, a fresh event
1225
+ under a genuinely new offset and a maximally delayed one under the
1226
+ cached offset remain indistinguishable from this check alone — callers
1227
+ resolve that by only forgiving a candidate's disagreement once in a row
1228
+ (see ``_poll_candidate_sample``).
1229
+
1230
+ ``previous`` itself may already be a delayed tick that was only
1231
+ explained by the cached offset because of the elapsed time *before*
1232
+ it — the elapsed time since ``previous`` alone assumes ``previous`` was
1233
+ current when observed, which understates how old the cached offset can
1234
+ still explain ``sample`` as being. The allowance therefore also carries
1235
+ forward ``previous``'s own inferred age under the cached offset (how
1236
+ much older than ``cached_offset_seconds`` would place it, ``previous``
1237
+ itself already was), so a second, less-delayed sample in the same
1238
+ unchanged-offset chain is not mistaken for refuting evidence merely
1239
+ because little host time elapsed since the last poll. The combined
1240
+ allowance is still capped at ``_MAX_CACHED_OFFSET_AGE_TOLERANCE_SECONDS``,
1241
+ so a genuine transition can never be masked this way regardless of how
1242
+ delayed ``previous`` was.
1243
+
1244
+ Returns:
1245
+ True when ``sample.raw_offset`` falls within ``cached_offset_seconds``
1246
+ widened downward by the elapsed time since ``previous`` plus
1247
+ ``previous``'s own inferred age under the cached offset (an older
1248
+ event yields a smaller raw offset), capped at
1249
+ ``_MAX_CACHED_OFFSET_AGE_TOLERANCE_SECONDS``, and by the fixed
1250
+ residual tolerance on both sides.
1251
+ """
1252
+ elapsed_seconds = max(0.0, sample.host_epoch - previous.host_epoch)
1253
+ previous_age_seconds = max(
1254
+ 0.0, cached_offset_seconds - (previous.tick_epoch - previous.host_epoch)
1255
+ )
1256
+ age_tolerance = min(
1257
+ elapsed_seconds + previous_age_seconds,
1258
+ _MAX_CACHED_OFFSET_AGE_TOLERANCE_SECONDS,
1259
+ )
1260
+ lower = cached_offset_seconds - (age_tolerance + _OFFSET_RESIDUAL_TOLERANCE_SECONDS)
1261
+ upper = cached_offset_seconds + _OFFSET_RESIDUAL_TOLERANCE_SECONDS
1262
+ return lower <= sample.raw_offset <= upper
1263
+
1264
+
1182
1265
  class TickClockNormalizer:
1183
1266
  """Connection-scoped UTC normalization for live MT5 tick timestamps.
1184
1267
 
@@ -1212,14 +1295,24 @@ class TickClockNormalizer:
1212
1295
  A cached calibration is revalidated after ``max_calibration_age_seconds``
1213
1296
  (covering DST transitions on the broker side), immediately when a
1214
1297
  normalized timestamp lands implausibly far in the future (evidence the
1215
- broker offset grew), and periodically via one confirming sample well
1216
- before the cache would otherwise expire (evidence the broker offset
1217
- shrank, which a future-skew check alone cannot catch since it looks like
1218
- ordinary staleness rather than an anomaly). Failed calibrations are
1219
- retried at most once per ``failed_calibration_retry_seconds`` rather than
1220
- on every call. When no offset can be established safely, normalized
1221
- snapshots fail closed with ``clock_status="uncalibrated"`` and
1222
- ``time_utc=None`` instead of treating the raw timestamp as UTC.
1298
+ broker offset grew), and periodically through one full revalidation round
1299
+ over every configured symbol well before the cache would otherwise
1300
+ expire (evidence the broker offset shrank, which a future-skew check
1301
+ alone cannot catch since it looks like ordinary staleness rather than an
1302
+ anomaly). The whole round is evaluated before deciding: any advancing
1303
+ sample that refutes the cached offset — a clean disagreement, or an
1304
+ advancing tick whose raw offset is unusable (``unstable_offset`` /
1305
+ ``implausible_offset``) and not otherwise explained by the cached offset
1306
+ given the real elapsed time since it was last observed and that prior
1307
+ observation's own inferred delay — invalidates the cache and forces
1308
+ full recalibration, while confirming or inconclusive symbols never
1309
+ cancel that evidence. Failed calibrations are retried at
1310
+ most once per ``failed_calibration_retry_seconds`` rather than on every
1311
+ call. When no
1312
+ offset can be established safely, normalized snapshots fail closed with
1313
+ ``clock_status="uncalibrated"`` and ``time_utc=None`` instead of
1314
+ treating the raw timestamp as UTC, and a cached offset that advancing
1315
+ evidence has just refuted is never applied to another tick.
1223
1316
 
1224
1317
  Keep one instance per MT5 connection/account; the calibration is a
1225
1318
  property of the broker server, not of an individual symbol.
@@ -1267,13 +1360,24 @@ class TickClockNormalizer:
1267
1360
  checks of an otherwise still-fresh cached calibration. Every
1268
1361
  configured symbol is polled once and the whole round is
1269
1362
  collected before deciding; any accepted sample that disagrees
1270
- with the cached offset forces full recalibration, even when
1271
- another symbol in the same round still confirms the cache
1272
- (mixed offsets are contradictory evidence for a
1273
- connection-scoped offset). This catches an offset decrease
1274
- (for example a UTC+3 to UTC+2 transition) well before
1275
- ``max_calibration_age_seconds`` would, while a confirming
1276
- symbol cannot cancel another symbol's fresh disagreement.
1363
+ with the cached offset, and any advancing sample whose offset
1364
+ is unusable (``unstable_offset`` / ``implausible_offset``) and
1365
+ not otherwise explained by the cached offset given the real
1366
+ elapsed time since that symbol's prior observation and that
1367
+ observation's own inferred delay, invalidates the cache and
1368
+ forces full recalibration, even when another symbol in the
1369
+ same round still confirms the cache (contradictory evidence
1370
+ for a connection-scoped offset must fail closed, not be
1371
+ averaged away). This catches an offset decrease (for example
1372
+ a UTC+3 to UTC+2 transition) well before
1373
+ ``max_calibration_age_seconds`` would, while a confirming or
1374
+ inconclusive symbol cannot cancel another symbol's refuting
1375
+ evidence. A disagreement one bucket away that is only
1376
+ explained by the elapsed time since the last observation and
1377
+ that observation's own inferred delay is forgiven once per
1378
+ symbol; if it recurs on that symbol's very next round instead
1379
+ of resolving back to the cached offset, it invalidates the
1380
+ cache on that second round instead.
1277
1381
  failed_calibration_retry_seconds: Minimum time between full
1278
1382
  recalibration attempts after a failed calibration, so a
1279
1383
  closed or illiquid market does not retry on every call.
@@ -1311,6 +1415,7 @@ class TickClockNormalizer:
1311
1415
  self._calibration: TickClockCalibration | None = None
1312
1416
  self._last_attempt_at: float | None = None
1313
1417
  self._last_observations: dict[str, _LiveObservation] = {}
1418
+ self._pending_disagreement: dict[str, _OffsetSample] = {}
1314
1419
 
1315
1420
  @property
1316
1421
  def calibration(self) -> TickClockCalibration | None:
@@ -1348,9 +1453,12 @@ class TickClockNormalizer:
1348
1453
  # scored as a fresh event merely because it differs from that old
1349
1454
  # observation, even though it may itself be an equally stale closing
1350
1455
  # tick. Dropping any prior baseline forces every symbol's first poll
1351
- # in this attempt to establish a fresh one instead.
1456
+ # in this attempt to establish a fresh one instead. Any pending,
1457
+ # not-yet-repeated disagreement recorded against the old cached
1458
+ # offset is equally stale once that offset is replaced.
1352
1459
  for resolved_symbol in resolved:
1353
1460
  self._last_observations.pop(resolved_symbol, None)
1461
+ self._pending_disagreement.pop(resolved_symbol, None)
1354
1462
  matched, failures, last_sample = self._collect_offset_samples(resolved)
1355
1463
  calibration = self._build_calibration(matched, failures, last_sample)
1356
1464
  self._calibration = calibration
@@ -1494,6 +1602,34 @@ class TickClockNormalizer:
1494
1602
  new one, and that contradictory evidence must force recalibration
1495
1603
  rather than let the confirming symbol cancel it.
1496
1604
 
1605
+ A candidate is *inconclusive* — and simply skipped — when its tick
1606
+ fetch is unusable, when it has no prior observation to compare
1607
+ against, or when its tick epoch is unchanged since that observation
1608
+ (ordinary tick age, e.g. a closed or illiquid symbol). Every other
1609
+ advancing sample is evidence the cache must be able to explain:
1610
+
1611
+ * an accepted offset equal to ``cached.offset_seconds`` confirms the
1612
+ cache, but never cancels another candidate's refuting evidence;
1613
+ * any other advancing sample — an accepted offset that disagrees with
1614
+ the cache, or one with no usable offset at all (``unstable_offset``
1615
+ or ``implausible_offset``) — refutes it, unless
1616
+ ``cached.offset_seconds`` still explains its raw offset given the
1617
+ real elapsed time since the candidate's prior observation and that
1618
+ observation's own inferred delay: a merely delayed event that
1619
+ defeated the generic bucket-rounding in
1620
+ ``_evaluate_advancement()``, landing on the wrong bucket or no
1621
+ bucket at all, is inconclusive, not contradictory, evidence.
1622
+
1623
+ Any refuting evidence invalidates the cached calibration and triggers
1624
+ a full recalibration, which either re-establishes a validated offset
1625
+ or fails closed; a raw tick is never normalized with a cached offset
1626
+ that advancing evidence has just refuted. When several candidates
1627
+ refute the cache in one round, the evidence reported is deterministic:
1628
+ ``implausible_offset`` first, then ``unstable_offset``, then a clean
1629
+ offset disagreement (the same severity order as
1630
+ ``_CALIBRATION_FAILURE_PRIORITY``), keeping the first-seen candidate
1631
+ within each kind.
1632
+
1497
1633
  ``_last_attempt_at`` is only advanced once some candidate actually had
1498
1634
  a prior observation to compare against. When every candidate has no
1499
1635
  baseline yet (for example a configured symbol that was never part of
@@ -1503,38 +1639,174 @@ class TickClockNormalizer:
1503
1639
  comparison.
1504
1640
 
1505
1641
  Returns:
1506
- A freshly recalibrated :class:`TickClockCalibration` when a fresh
1507
- sample disagreed with ``cached``, or ``None`` when the cached
1508
- calibration should be kept as-is.
1642
+ A freshly recalibrated :class:`TickClockCalibration` when any
1643
+ advancing sample refuted ``cached``, or ``None`` when the round
1644
+ only confirmed the cache or was wholly inconclusive.
1509
1645
  """
1646
+ refuting = self._collect_refuting_evidence(cached, symbol, now_epoch=now_epoch)
1647
+ if refuting is None:
1648
+ return None
1649
+ candidate, sample = refuting
1650
+ # Invalidate before recalibrating: no snapshot may be normalized with
1651
+ # a cached offset that advancing evidence has just refuted, even if
1652
+ # the recalibration attempt below cannot complete.
1653
+ self._calibration = None
1654
+ if sample.offset_seconds is None:
1655
+ _logger.warning(
1656
+ "MT5 server clock revalidation for %s produced unusable"
1657
+ " advancing evidence (%s, raw offset %.0f seconds) against the"
1658
+ " cached %.0f-second offset; invalidating it and"
1659
+ " recalibrating.",
1660
+ candidate,
1661
+ sample.reason,
1662
+ sample.raw_offset,
1663
+ cached.offset_seconds,
1664
+ )
1665
+ else:
1666
+ _logger.warning(
1667
+ "MT5 server clock offset for %s appears to have changed from"
1668
+ " %.0f to %.0f seconds; recalibrating.",
1669
+ candidate,
1670
+ cached.offset_seconds,
1671
+ sample.offset_seconds,
1672
+ )
1673
+ return self.calibrate(self._symbols or (symbol,))
1674
+
1675
+ def _collect_refuting_evidence(
1676
+ self,
1677
+ cached: TickClockCalibration,
1678
+ symbol: str,
1679
+ *,
1680
+ now_epoch: float,
1681
+ ) -> tuple[str, _OffsetSample] | None:
1682
+ """Poll every configured symbol once and select refuting evidence.
1683
+
1684
+ Each candidate is compared against its last stored observation (and
1685
+ that observation is refreshed). Candidates with an unusable fetch, no
1686
+ prior baseline, or an unchanged tick epoch are inconclusive and
1687
+ skipped; a comparison that actually ran advances ``_last_attempt_at``.
1688
+ Confirming samples (an accepted offset equal to the cached one) are
1689
+ recorded nowhere — they never cancel refuting evidence. Any other
1690
+ advancing sample — one whose raw offset defeated the generic
1691
+ bucket-rounding in ``_evaluate_advancement()`` (``unstable_offset`` /
1692
+ ``implausible_offset``), or one that rounded cleanly to a *different*
1693
+ bucket than the cached offset — is also inconclusive, not refuting,
1694
+ when ``cached.offset_seconds`` still explains it given the real
1695
+ elapsed time since the candidate's prior observation and that
1696
+ observation's own inferred delay: unlike the rounding in
1697
+ ``_evaluate_advancement()``, this check has a specific known offset
1698
+ to test against, so widening by the true (uncapped) elapsed time
1699
+ cannot select the wrong bucket. That forgiveness is only
1700
+ granted once per candidate in a row (see ``_poll_candidate_sample``),
1701
+ because a single sample cannot otherwise be told apart from a fresh
1702
+ event one bucket away under a genuinely new offset.
1703
+
1704
+ Returns:
1705
+ The ``(symbol, sample)`` evidence invalidating ``cached``, chosen
1706
+ deterministically — ``implausible_offset`` first, then
1707
+ ``unstable_offset``, then a clean offset disagreement, first-seen
1708
+ within each kind — or ``None`` when the round found none.
1709
+ """
1710
+ unusable: dict[CalibrationStatus, tuple[str, _OffsetSample]] = {}
1510
1711
  changed_sample: tuple[str, _OffsetSample] | None = None
1511
1712
  for candidate in self._symbols or (symbol,):
1512
- previous = self._last_observations.get(candidate)
1513
- observation = _fetch_live_observation(self._client, candidate)
1514
- if observation is None:
1515
- continue
1516
- self._last_observations[candidate] = observation
1517
- if previous is None:
1518
- continue
1519
- self._last_attempt_at = now_epoch
1520
- sample = _evaluate_advancement(previous, observation)
1521
- if sample is None or sample.offset_seconds is None:
1522
- continue
1523
- if sample.offset_seconds == cached.offset_seconds:
1713
+ # cached.offset_seconds is only None when status != "calibrated",
1714
+ # and callers only reach revalidation with a calibrated cache.
1715
+ sample = self._poll_candidate_sample(
1716
+ candidate, cast("float", cached.offset_seconds), now_epoch=now_epoch
1717
+ )
1718
+ if sample is None:
1524
1719
  continue
1525
- if changed_sample is None:
1720
+ if sample.offset_seconds is None:
1721
+ unusable.setdefault(sample.reason, (candidate, sample))
1722
+ elif (
1723
+ sample.offset_seconds != cached.offset_seconds
1724
+ and changed_sample is None
1725
+ ):
1526
1726
  changed_sample = (candidate, sample)
1527
- if changed_sample is None:
1727
+ for reason in _CALIBRATION_FAILURE_PRIORITY:
1728
+ if reason in unusable:
1729
+ return unusable[reason]
1730
+ return changed_sample
1731
+
1732
+ def _poll_candidate_sample(
1733
+ self,
1734
+ candidate: str,
1735
+ cached_offset_seconds: float,
1736
+ *,
1737
+ now_epoch: float,
1738
+ ) -> _OffsetSample | None:
1739
+ """Poll one revalidation candidate and score it against ``cached``.
1740
+
1741
+ Refreshes ``self._last_observations[candidate]`` and advances
1742
+ ``self._last_attempt_at`` whenever a comparison actually ran (the
1743
+ candidate had a prior observation). Any advancing sample that
1744
+ disagrees with ``cached_offset_seconds`` — an unusable
1745
+ ``unstable_offset`` / ``implausible_offset`` sample, or a *usable*
1746
+ sample that simply rounded to a different bucket — that
1747
+ ``cached_offset_seconds`` still explains, given the real elapsed time
1748
+ since the prior observation, is treated as inconclusive rather than
1749
+ refuting the *first* time it is seen for ``candidate``.
1750
+
1751
+ A single such sample cannot, by raw offset and elapsed time alone, be
1752
+ told apart from a genuinely new offset one bucket away: a fresh event
1753
+ under that new offset produces exactly the same raw offset as a
1754
+ maximally stale event under the cached one once the polling gap
1755
+ reaches ``_MAX_CACHED_OFFSET_AGE_TOLERANCE_SECONDS``
1756
+ (:func:`_is_advancing_sample_compatible_with_cached`). A real
1757
+ transition keeps producing fresh, *usable* ticks that round to the
1758
+ *same* alternate bucket on the following rounds too, while an
1759
+ isolated delayed tick does not, so ``self._pending_disagreement``
1760
+ remembers the first such sample per candidate and only escalates once
1761
+ a later sample rounds to that identical bucket. An ``unstable_offset``
1762
+ / ``implausible_offset`` sample cannot be told apart from noise near
1763
+ any bucket, so it can neither confirm nor be recorded as a pending
1764
+ disagreement; like a usable sample landing on a *different* bucket
1765
+ than the one already pending, it breaks the run and replaces or
1766
+ clears the stored entry instead of letting an unrelated pair of
1767
+ samples accumulate into false corroboration.
1768
+
1769
+ Returns:
1770
+ The candidate's :class:`_OffsetSample` when it is evidence to
1771
+ weigh against ``cached_offset_seconds``, or ``None`` when the
1772
+ candidate's fetch is unusable, it has no prior observation, its
1773
+ tick epoch is unchanged, or its nonmatching offset is explained by
1774
+ ``cached_offset_seconds`` without yet repeating the same bucket.
1775
+ """
1776
+ previous = self._last_observations.get(candidate)
1777
+ observation = _fetch_live_observation(self._client, candidate)
1778
+ if observation is None:
1779
+ self._pending_disagreement.pop(candidate, None)
1528
1780
  return None
1529
- candidate, sample = changed_sample
1530
- _logger.warning(
1531
- "MT5 server clock offset for %s appears to have changed from"
1532
- " %.0f to %.0f seconds; recalibrating.",
1533
- candidate,
1534
- cached.offset_seconds,
1535
- sample.offset_seconds,
1536
- )
1537
- return self.calibrate(self._symbols or (symbol,))
1781
+ self._last_observations[candidate] = observation
1782
+ if previous is None:
1783
+ self._pending_disagreement.pop(candidate, None)
1784
+ return None
1785
+ self._last_attempt_at = now_epoch
1786
+ sample = _evaluate_advancement(previous, observation)
1787
+ if sample is None:
1788
+ self._pending_disagreement.pop(candidate, None)
1789
+ return None
1790
+ if sample.offset_seconds == cached_offset_seconds or not (
1791
+ _is_advancing_sample_compatible_with_cached(
1792
+ sample, cached_offset_seconds, previous
1793
+ )
1794
+ ):
1795
+ self._pending_disagreement.pop(candidate, None)
1796
+ return sample
1797
+ pending = self._pending_disagreement.get(candidate)
1798
+ if (
1799
+ sample.offset_seconds is not None
1800
+ and pending is not None
1801
+ and pending.offset_seconds == sample.offset_seconds
1802
+ ):
1803
+ del self._pending_disagreement[candidate]
1804
+ return sample
1805
+ if sample.offset_seconds is None:
1806
+ self._pending_disagreement.pop(candidate, None)
1807
+ else:
1808
+ self._pending_disagreement[candidate] = sample
1809
+ return None
1538
1810
 
1539
1811
  def _collect_offset_samples(
1540
1812
  self,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "1.3.3"
3
+ version = "1.4.0"
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"}]