mt5cli 1.3.1__tar.gz → 1.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mt5cli-1.3.1 → mt5cli-1.3.2}/PKG-INFO +1 -1
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/trading.md +10 -8
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/trading.py +41 -30
- {mt5cli-1.3.1 → mt5cli-1.3.2}/pyproject.toml +1 -1
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_trading.py +133 -2
- {mt5cli-1.3.1 → mt5cli-1.3.2}/uv.lock +1 -1
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.claude/agents/codex.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.claude/settings.json +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/FUNDING.yml +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/dependabot.yml +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/renovate.json +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/workflows/ci.yml +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/workflows/claude.yml +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.github/workflows/release.yml +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/.gitignore +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/AGENTS.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/CLAUDE.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/LICENSE +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/README.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/cli.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/client.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/converters.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/exceptions.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/grafana.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/history.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/index.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/marketdata.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/observability.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/public-contract.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/schemas.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/telemetry.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/api/utils.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/docs/index.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/README.md +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/compose.yml +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/dashboards/mt5cli-market.json +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/dashboards/mt5cli-overview.json +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/dashboards/mt5cli-trades.json +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/provisioning/dashboards/mt5cli.yml +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/examples/grafana/provisioning/datasources/mt5cli-sqlite.yml +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mkdocs.yml +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/__init__.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/__main__.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/cli.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/client.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/contract.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/converters.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/exceptions.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/grafana.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/history.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/marketdata.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/observability.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/retry.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/schemas.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/telemetry.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/mt5cli/utils.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/__init__.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/conftest.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_cli.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_client.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_contracts.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_examples.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_grafana.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_history.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_marketdata.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_observability.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_packaging.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_telemetry.py +0 -0
- {mt5cli-1.3.1 → mt5cli-1.3.2}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt5cli
|
|
3
|
-
Version: 1.3.
|
|
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
|
|
|
@@ -304,11 +305,12 @@ symbols and calls. A cached offset is recomputed in three cases:
|
|
|
304
305
|
future — evidence that the broker offset grew, e.g. a UTC+2 to UTC+3
|
|
305
306
|
transition.
|
|
306
307
|
- Periodically, at most once per `revalidation_interval_seconds` (default 5
|
|
307
|
-
minutes),
|
|
308
|
-
offset
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
308
|
+
minutes), configured symbols are sampled until one confirms the
|
|
309
|
+
still-cached offset. Closed or inconclusive symbols are skipped; a detected
|
|
310
|
+
disagreement forces full recalibration. This is what catches a broker
|
|
311
|
+
offset _decrease_ (e.g. UTC+3 to UTC+2): the resulting normalized time
|
|
312
|
+
looks stale rather than future, which a future-skew check alone cannot
|
|
313
|
+
distinguish from ordinary quiet-market staleness.
|
|
312
314
|
|
|
313
315
|
If recalibration still cannot validate the timestamp, the snapshot fails
|
|
314
316
|
closed. Failed calibrations are recorded for diagnostics but never reused;
|
|
@@ -1028,18 +1028,25 @@ def _match_value(value: object) -> float | None:
|
|
|
1028
1028
|
def _ticks_match(live: Mapping[str, object], copied: Mapping[str, object]) -> bool:
|
|
1029
1029
|
"""Decide whether a live tick and a copied tick are the same market event.
|
|
1030
1030
|
|
|
1031
|
-
Every field present on both sides
|
|
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
|
|
|
@@ -1122,7 +1128,7 @@ def _matching_ticks_newest_first(
|
|
|
1122
1128
|
live-vs-copied comparison is in flight, so every recent copied row is
|
|
1123
1129
|
searched for the same market event as ``live`` instead of assuming the
|
|
1124
1130
|
newest copied row is the relevant one. A newer row can also
|
|
1125
|
-
coincidentally share ``live``'s price
|
|
1131
|
+
coincidentally share ``live``'s price fields without being the
|
|
1126
1132
|
same event, so every match is returned for offset-plausibility
|
|
1127
1133
|
evaluation rather than only the newest.
|
|
1128
1134
|
|
|
@@ -1144,7 +1150,7 @@ def _sample_clock_offset(
|
|
|
1144
1150
|
"""Compare one live tick against recent copied UTC ticks.
|
|
1145
1151
|
|
|
1146
1152
|
Every field-matching copied tick is evaluated, newest first, so a newer
|
|
1147
|
-
row that coincidentally shares the live tick's price
|
|
1153
|
+
row that coincidentally shares the live tick's price fields
|
|
1148
1154
|
cannot shadow a plausible offset from an older exact match; recency
|
|
1149
1155
|
only breaks ties among candidates that are themselves plausible.
|
|
1150
1156
|
|
|
@@ -1258,12 +1264,13 @@ class TickClockNormalizer:
|
|
|
1258
1264
|
is recomputed unconditionally (bounds DST-transition
|
|
1259
1265
|
staleness).
|
|
1260
1266
|
revalidation_interval_seconds: Minimum time between opportunistic
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
+
checks of an otherwise still-fresh cached calibration.
|
|
1268
|
+
Configured symbols are sampled until one confirms the cache;
|
|
1269
|
+
a disagreeing sample forces full recalibration only when none
|
|
1270
|
+
confirms it. This catches an offset decrease (for example a
|
|
1271
|
+
UTC+3 to UTC+2 transition) well before
|
|
1272
|
+
``max_calibration_age_seconds`` would, while a closed symbol
|
|
1273
|
+
cannot hide fresh evidence from another active symbol.
|
|
1267
1274
|
failed_calibration_retry_seconds: Minimum time between full
|
|
1268
1275
|
recalibration attempts after a failed calibration, so a
|
|
1269
1276
|
closed or illiquid market does not retry on every call.
|
|
@@ -1451,36 +1458,40 @@ class TickClockNormalizer:
|
|
|
1451
1458
|
*,
|
|
1452
1459
|
now_epoch: float,
|
|
1453
1460
|
) -> TickClockCalibration | None:
|
|
1454
|
-
"""Confirm or replace a still-fresh cached calibration with
|
|
1461
|
+
"""Confirm or replace a still-fresh cached calibration with live symbols.
|
|
1455
1462
|
|
|
1456
1463
|
A future-skew check alone never catches a broker offset *decrease*
|
|
1457
1464
|
(e.g. a UTC+3 to UTC+2 transition): the resulting normalized time
|
|
1458
1465
|
looks stale rather than future, and staleness is also the expected
|
|
1459
|
-
symptom of a quiet market. This periodic
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1466
|
+
symptom of a quiet market. This periodic check probes the configured
|
|
1467
|
+
symbols until one confirms the cached offset. Inconclusive samples
|
|
1468
|
+
from closed markets are skipped, so one regional symbol cannot hide
|
|
1469
|
+
fresh evidence from another active symbol.
|
|
1463
1470
|
|
|
1464
1471
|
Returns:
|
|
1465
1472
|
A freshly recalibrated :class:`TickClockCalibration` when the
|
|
1466
1473
|
fresh sample disagreed with ``cached``, or ``None`` when the
|
|
1467
1474
|
cached calibration should be kept as-is.
|
|
1468
1475
|
"""
|
|
1469
|
-
sample = _sample_clock_offset(
|
|
1470
|
-
self._client,
|
|
1471
|
-
symbol,
|
|
1472
|
-
window_seconds=self._copied_window_seconds,
|
|
1473
|
-
)
|
|
1474
1476
|
self._last_attempt_at = now_epoch
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1477
|
+
changed_sample: tuple[str, _OffsetSample] | None = None
|
|
1478
|
+
for candidate in self._symbols or (symbol,):
|
|
1479
|
+
sample = _sample_clock_offset(
|
|
1480
|
+
self._client,
|
|
1481
|
+
candidate,
|
|
1482
|
+
window_seconds=self._copied_window_seconds,
|
|
1483
|
+
)
|
|
1484
|
+
if sample.offset_seconds == cached.offset_seconds:
|
|
1485
|
+
return None
|
|
1486
|
+
if sample.offset_seconds is not None and changed_sample is None:
|
|
1487
|
+
changed_sample = (candidate, sample)
|
|
1488
|
+
if changed_sample is None:
|
|
1479
1489
|
return None
|
|
1490
|
+
candidate, sample = changed_sample
|
|
1480
1491
|
_logger.warning(
|
|
1481
1492
|
"MT5 server clock offset for %s appears to have changed from"
|
|
1482
1493
|
" %.0f to %.0f seconds; recalibrating.",
|
|
1483
|
-
|
|
1494
|
+
candidate,
|
|
1484
1495
|
cached.offset_seconds,
|
|
1485
1496
|
sample.offset_seconds,
|
|
1486
1497
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mt5cli"
|
|
3
|
-
version = "1.3.
|
|
3
|
+
version = "1.3.2"
|
|
4
4
|
description = "Generic MT5 data and execution infrastructure for Python applications"
|
|
5
5
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
6
6
|
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
@@ -4795,6 +4795,88 @@ class TestTickClockNormalizer:
|
|
|
4795
4795
|
_assert_close(calibration.calibrated_at, _CLOCK_NOW_EPOCH)
|
|
4796
4796
|
assert client.copy_ticks_range.call_count == 3
|
|
4797
4797
|
|
|
4798
|
+
def test_revalidation_uses_active_configured_symbol_when_one_is_closed(
|
|
4799
|
+
self,
|
|
4800
|
+
mocker: MockerFixture,
|
|
4801
|
+
) -> None:
|
|
4802
|
+
"""One closed symbol cannot hide fresh connection-wide evidence."""
|
|
4803
|
+
mock_dt, _ = _freeze_clock(mocker)
|
|
4804
|
+
event_1 = _CLOCK_NOW_EPOCH - 2
|
|
4805
|
+
event_2 = _CLOCK_NOW_EPOCH - 1
|
|
4806
|
+
later = datetime.fromtimestamp(_CLOCK_NOW_EPOCH + 61.0, tz=UTC)
|
|
4807
|
+
later_event = later.timestamp() - 1
|
|
4808
|
+
client = _clock_client(
|
|
4809
|
+
[
|
|
4810
|
+
_live_tick(event_1 + _UTC_PLUS_3),
|
|
4811
|
+
_live_tick(event_2 + _UTC_PLUS_3, bid=150.0, ask=150.1),
|
|
4812
|
+
_live_tick(event_1 + _UTC_PLUS_3), # closed revalidation sample
|
|
4813
|
+
_live_tick(later_event + _UTC_PLUS_3, bid=150.0, ask=150.1),
|
|
4814
|
+
_live_tick(later_event + _UTC_PLUS_3, bid=150.0, ask=150.1),
|
|
4815
|
+
],
|
|
4816
|
+
[
|
|
4817
|
+
_copied_frame(event_1),
|
|
4818
|
+
_copied_frame(event_2, bid=150.0, ask=150.1),
|
|
4819
|
+
pd.DataFrame(),
|
|
4820
|
+
_copied_frame(later_event, bid=150.0, ask=150.1),
|
|
4821
|
+
],
|
|
4822
|
+
)
|
|
4823
|
+
normalizer = TickClockNormalizer(
|
|
4824
|
+
client,
|
|
4825
|
+
["CLOSED", "ACTIVE"],
|
|
4826
|
+
samples_per_symbol=1,
|
|
4827
|
+
revalidation_interval_seconds=60.0,
|
|
4828
|
+
)
|
|
4829
|
+
assert normalizer.calibrate().status == "calibrated"
|
|
4830
|
+
|
|
4831
|
+
mock_dt.now.return_value = later
|
|
4832
|
+
snapshot = normalizer.get_normalized_tick_snapshot("ACTIVE")
|
|
4833
|
+
|
|
4834
|
+
assert snapshot["clock_status"] == "calibrated"
|
|
4835
|
+
assert snapshot["time_utc"] == datetime.fromtimestamp(later_event, tz=UTC)
|
|
4836
|
+
_assert_close(snapshot["server_clock_offset_seconds"], _UTC_PLUS_3)
|
|
4837
|
+
assert [
|
|
4838
|
+
call.kwargs["symbol"]
|
|
4839
|
+
for call in client.symbol_info_tick_as_dict.call_args_list
|
|
4840
|
+
] == ["CLOSED", "ACTIVE", "CLOSED", "ACTIVE", "ACTIVE"]
|
|
4841
|
+
|
|
4842
|
+
def test_inconclusive_revalidation_keeps_cached_offset(
|
|
4843
|
+
self,
|
|
4844
|
+
mocker: MockerFixture,
|
|
4845
|
+
) -> None:
|
|
4846
|
+
"""A closed market does not discard a still-valid cached offset."""
|
|
4847
|
+
mock_dt, _ = _freeze_clock(mocker)
|
|
4848
|
+
event_1 = _CLOCK_NOW_EPOCH - 2
|
|
4849
|
+
event_2 = _CLOCK_NOW_EPOCH - 1
|
|
4850
|
+
later = datetime.fromtimestamp(_CLOCK_NOW_EPOCH + 61.0, tz=UTC)
|
|
4851
|
+
client = _clock_client(
|
|
4852
|
+
[
|
|
4853
|
+
_live_tick(event_1 + _UTC_PLUS_3),
|
|
4854
|
+
_live_tick(event_2 + _UTC_PLUS_3),
|
|
4855
|
+
_live_tick(event_2 + _UTC_PLUS_3),
|
|
4856
|
+
_live_tick(event_2 + _UTC_PLUS_3),
|
|
4857
|
+
],
|
|
4858
|
+
[
|
|
4859
|
+
_copied_frame(event_1),
|
|
4860
|
+
_copied_frame(event_2),
|
|
4861
|
+
pd.DataFrame(),
|
|
4862
|
+
],
|
|
4863
|
+
)
|
|
4864
|
+
normalizer = TickClockNormalizer(
|
|
4865
|
+
client,
|
|
4866
|
+
["SING30"],
|
|
4867
|
+
samples_per_symbol=2,
|
|
4868
|
+
revalidation_interval_seconds=60.0,
|
|
4869
|
+
)
|
|
4870
|
+
first = normalizer.calibrate()
|
|
4871
|
+
_assert_close(first.offset_seconds, _UTC_PLUS_3)
|
|
4872
|
+
|
|
4873
|
+
mock_dt.now.return_value = later
|
|
4874
|
+
snapshot = normalizer.get_normalized_tick_snapshot("SING30")
|
|
4875
|
+
|
|
4876
|
+
assert snapshot["clock_status"] == "calibrated"
|
|
4877
|
+
_assert_close(snapshot["server_clock_offset_seconds"], _UTC_PLUS_3)
|
|
4878
|
+
assert client.copy_ticks_range.call_count == 3
|
|
4879
|
+
|
|
4798
4880
|
def test_offset_decrease_is_caught_by_periodic_revalidation(
|
|
4799
4881
|
self,
|
|
4800
4882
|
mocker: MockerFixture,
|
|
@@ -4913,14 +4995,12 @@ class TestTickClockNormalizer:
|
|
|
4913
4995
|
[
|
|
4914
4996
|
({"bid": None, "ask": None, "last": None}, {}),
|
|
4915
4997
|
({"bid": 0.0, "ask": None, "last": None}, {"bid": 0.0}),
|
|
4916
|
-
({"volume": 6}, {}),
|
|
4917
4998
|
({"bid": "1.1", "ask": None, "last": None}, {}),
|
|
4918
4999
|
({"bid": float("nan"), "ask": None, "last": None}, {}),
|
|
4919
5000
|
],
|
|
4920
5001
|
ids=[
|
|
4921
5002
|
"volume-only",
|
|
4922
5003
|
"zero-price-only",
|
|
4923
|
-
"volume-mismatch",
|
|
4924
5004
|
"string-price",
|
|
4925
5005
|
"nan-price",
|
|
4926
5006
|
],
|
|
@@ -4948,6 +5028,57 @@ class TestTickClockNormalizer:
|
|
|
4948
5028
|
|
|
4949
5029
|
assert normalizer.calibrate().status == "no_matching_event"
|
|
4950
5030
|
|
|
5031
|
+
def test_volume_difference_does_not_hide_matching_quote_event(
|
|
5032
|
+
self,
|
|
5033
|
+
mocker: MockerFixture,
|
|
5034
|
+
) -> None:
|
|
5035
|
+
"""Bid/ask identity remains usable when live and copied volumes differ."""
|
|
5036
|
+
_freeze_clock(mocker)
|
|
5037
|
+
event = _CLOCK_NOW_EPOCH - 1
|
|
5038
|
+
client = _clock_client(
|
|
5039
|
+
[_live_tick(event + _UTC_PLUS_3, volume=6)],
|
|
5040
|
+
_copied_frame(event, volume=5),
|
|
5041
|
+
)
|
|
5042
|
+
normalizer = TickClockNormalizer(
|
|
5043
|
+
client,
|
|
5044
|
+
["SING30"],
|
|
5045
|
+
samples_per_symbol=1,
|
|
5046
|
+
min_agreeing_samples=1,
|
|
5047
|
+
)
|
|
5048
|
+
|
|
5049
|
+
calibration = normalizer.calibrate()
|
|
5050
|
+
|
|
5051
|
+
assert calibration.status == "calibrated"
|
|
5052
|
+
_assert_close(calibration.offset_seconds, _UTC_PLUS_3)
|
|
5053
|
+
|
|
5054
|
+
def test_half_hour_delayed_feed_is_not_inferred_from_host_clock(
|
|
5055
|
+
self,
|
|
5056
|
+
mocker: MockerFixture,
|
|
5057
|
+
) -> None:
|
|
5058
|
+
"""Advancing stale ticks cannot become fresh through host-time rounding."""
|
|
5059
|
+
_freeze_clock(mocker)
|
|
5060
|
+
delayed_event_1 = _CLOCK_NOW_EPOCH - 1802
|
|
5061
|
+
delayed_event_2 = _CLOCK_NOW_EPOCH - 1801
|
|
5062
|
+
copied = _copied_frame(delayed_event_1, delayed_event_2)
|
|
5063
|
+
client = _clock_client(
|
|
5064
|
+
[
|
|
5065
|
+
_live_tick(delayed_event_1 + _UTC_PLUS_3),
|
|
5066
|
+
_live_tick(delayed_event_2 + _UTC_PLUS_3),
|
|
5067
|
+
],
|
|
5068
|
+
pd.DataFrame(),
|
|
5069
|
+
)
|
|
5070
|
+
client.copy_ticks_range.side_effect = _windowed_copy_ticks_range(copied)
|
|
5071
|
+
normalizer = TickClockNormalizer(
|
|
5072
|
+
client,
|
|
5073
|
+
["SING30"],
|
|
5074
|
+
samples_per_symbol=2,
|
|
5075
|
+
)
|
|
5076
|
+
|
|
5077
|
+
calibration = normalizer.calibrate()
|
|
5078
|
+
|
|
5079
|
+
assert calibration.status == "no_copied_ticks"
|
|
5080
|
+
assert calibration.offset_seconds is None
|
|
5081
|
+
|
|
4951
5082
|
def test_copied_time_msc_datetime_and_time_fallbacks(
|
|
4952
5083
|
self,
|
|
4953
5084
|
mocker: MockerFixture,
|
|
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
|