feat: add injectable update_backend to ThrottledHistoryUpdater (#38)

* feat: add injectable update_backend to ThrottledHistoryUpdater

Allow downstream applications to substitute the history update backend via
the ThrottledHistoryUpdater constructor without monkey-patching
mt5cli.sdk.update_history. Defaults to update_history for backward
compatibility.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* chore: fix lint and format after QA

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* chore: bump version to 0.8.2

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* fix: use explicit None check for ThrottledHistoryUpdater backend

Only None selects the default update_history backend so falsy callable
objects with __bool__ returning False are preserved as custom backends.

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>
This commit is contained in:
Daichi Narushima
2026-06-18 22:58:09 +09:00
committed by GitHub
co-authored by Daichi Narushima Cursor Agent
parent 897f7f0a0d
commit 7de3ce0b7a
7 changed files with 209 additions and 18 deletions
+151
View File
@@ -2100,3 +2100,154 @@ class TestThrottledHistoryUpdater:
assert updater.update(MagicMock(), []) is False
update.assert_not_called()
assert updater.last_update_monotonic is None
def test_default_update_backend_is_update_history(self) -> None:
"""Test the default backend resolves to update_history."""
updater = ThrottledHistoryUpdater(output="history.db")
assert updater.update_backend is update_history
def test_falsy_callable_update_backend_is_preserved(
self,
mocker: MockerFixture,
) -> None:
"""Test only None selects the default backend, not falsy callables."""
class FalsyCallable:
def __init__(self) -> None:
self.calls: list[dict[str, object]] = []
def __bool__(self) -> bool:
return False
def __call__(self, **kwargs: object) -> None:
self.calls.append(kwargs)
falsy_backend = FalsyCallable()
default_backend = mocker.patch("mt5cli.sdk.update_history")
updater = ThrottledHistoryUpdater(
output="history.db",
update_backend=falsy_backend,
)
assert updater.update_backend is falsy_backend
client = MagicMock()
assert updater.update(client, ["EURUSD"]) is True
assert len(falsy_backend.calls) == 1
assert falsy_backend.calls[0]["client"] is client
assert falsy_backend.calls[0]["symbols"] == ["EURUSD"]
default_backend.assert_not_called()
def test_custom_update_backend_receives_expected_kwargs(
self,
mocker: MockerFixture,
) -> None:
"""Test a custom backend receives update_history keyword arguments."""
backend = mocker.Mock()
client = MagicMock()
updater = ThrottledHistoryUpdater(
output="history.db",
datasets={Dataset.rates},
timeframes=["M1", "H1"],
flags="INFO",
lookback_hours=12.0,
with_views=True,
include_account_events=False,
update_backend=backend,
)
updater.update(client, ["EURUSD", "GBPUSD"])
backend.assert_called_once_with(
client=client,
output="history.db",
symbols=["EURUSD", "GBPUSD"],
datasets={Dataset.rates},
timeframes=["M1", "H1"],
flags="INFO",
lookback_hours=12.0,
with_views=True,
include_account_events=False,
)
def test_throttled_calls_do_not_invoke_custom_backend(
self,
mocker: MockerFixture,
) -> None:
"""Test throttled update cycles skip the injected backend."""
backend = mocker.Mock()
monotonic = mocker.patch("mt5cli.sdk.time.monotonic")
monotonic.side_effect = [100.0, 105.0, 200.0, 200.0]
client = MagicMock()
updater = ThrottledHistoryUpdater(
output="history.db",
interval_seconds=60,
update_backend=backend,
)
assert updater.update(client, ["EURUSD"]) is True
assert updater.update(client, ["EURUSD"]) is False
assert updater.update(client, ["EURUSD"]) is True
assert backend.call_count == 2
def test_successful_custom_backend_advances_throttle(
self,
mocker: MockerFixture,
) -> None:
"""Test a successful custom backend updates _last_update_monotonic."""
backend = mocker.Mock()
monotonic = mocker.patch("mt5cli.sdk.time.monotonic", return_value=42.0)
updater = ThrottledHistoryUpdater(
output="history.db",
update_backend=backend,
)
assert updater.update(MagicMock(), ["EURUSD"]) is True
assert updater.last_update_monotonic is monotonic.return_value
monotonic.assert_called_once()
def test_failed_custom_backend_does_not_advance_throttle(
self,
mocker: MockerFixture,
) -> None:
"""Test a failing custom backend leaves _last_update_monotonic unchanged."""
backend = mocker.Mock(side_effect=Mt5RuntimeError("boom"))
updater = ThrottledHistoryUpdater(
output="history.db",
update_backend=backend,
)
with pytest.raises(Mt5RuntimeError, match="boom"):
updater.update(MagicMock(), ["EURUSD"])
assert updater.last_update_monotonic is None
def test_custom_backend_suppresses_recoverable_errors_when_requested(
self,
mocker: MockerFixture,
) -> None:
"""Test suppress_errors swallows recoverable custom backend errors."""
backend = mocker.Mock(side_effect=Mt5RuntimeError("boom"))
updater = ThrottledHistoryUpdater(
output="history.db",
suppress_errors=True,
update_backend=backend,
)
assert updater.update(MagicMock(), ["EURUSD"]) is False
assert updater.last_update_monotonic is None
def test_custom_backend_propagates_errors_when_not_suppressed(
self,
mocker: MockerFixture,
) -> None:
"""Test recoverable custom backend errors propagate by default."""
backend = mocker.Mock(side_effect=Mt5RuntimeError("boom"))
updater = ThrottledHistoryUpdater(
output="history.db",
update_backend=backend,
)
with pytest.raises(Mt5RuntimeError, match="boom"):
updater.update(MagicMock(), ["EURUSD"])
assert updater.last_update_monotonic is None