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