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.
- {mt5cli-1.3.3 → mt5cli-1.4.0}/PKG-INFO +1 -1
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/trading.md +54 -8
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/trading.py +315 -43
- {mt5cli-1.3.3 → mt5cli-1.4.0}/pyproject.toml +1 -1
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_trading.py +913 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/uv.lock +2 -2
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.claude/agents/codex.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.claude/settings.json +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/FUNDING.yml +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/dependabot.yml +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/renovate.json +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/workflows/ci.yml +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/workflows/claude.yml +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.github/workflows/release.yml +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/.gitignore +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/AGENTS.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/CLAUDE.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/LICENSE +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/README.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/cli.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/client.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/converters.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/exceptions.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/grafana.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/history.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/index.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/marketdata.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/observability.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/public-contract.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/schemas.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/telemetry.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/api/utils.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/docs/index.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/README.md +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/compose.yml +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/dashboards/mt5cli-market.json +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mkdocs.yml +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/__init__.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/__main__.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/cli.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/client.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/contract.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/converters.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/exceptions.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/grafana.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/history.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/marketdata.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/observability.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/retry.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/schemas.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/telemetry.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/mt5cli/utils.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/__init__.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/conftest.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_cli.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_client.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_contracts.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_examples.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_grafana.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_history.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_marketdata.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_observability.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_packaging.py +0 -0
- {mt5cli-1.3.3 → mt5cli-1.4.0}/tests/test_telemetry.py +0 -0
- {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
|
+
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
|
|
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.
|
|
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),
|
|
327
|
-
symbol
|
|
328
|
-
|
|
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
|
-
|
|
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
|
|
1216
|
-
before the cache would otherwise
|
|
1217
|
-
shrank, which a future-skew check
|
|
1218
|
-
ordinary staleness rather than an
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
``
|
|
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
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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
|
|
1507
|
-
sample
|
|
1508
|
-
|
|
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
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
if
|
|
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
|
|
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
|
-
|
|
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
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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
|
+
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"}]
|