feat: historical BTC rate lookup

Standalone piece for pricing a back-dated payout at the day it was due.
Nothing calls it yet; the pricing modes land next.

LNbits cannot answer this question. Its eight exchange providers are spot
tickers with no date parameter, and the rate history behind the admin chart
lives in TransientSettings — RAM only, wiped on restart, capped at
lnbits_exchange_history_size points, and collected for a single currency.
Measured on bohm: after 5.4h of uptime the buffer held 5h of USD at a 60
point cap, having already evicted its oldest points, with 85 fetch failures
leaving visible gaps. Fine for a monitoring graph, not for pricing a salary.

Kraken is primary and CoinGecko the fallback, decided on request count: one
OHLC?interval=1440 call returns ~720 daily candles, so a twelve-period
backfill costs a single request and every date is served from the parsed
series. CoinGecko is addressed by date, so the same backfill would be
twelve requests into free-tier rate limits — but it covers currencies
Kraken lists no pair for, and reaches back further than Kraken's ~2 years.
Kraken is also already one of the providers LNbits itself trusts.

The series is read from whichever result key is not "last": Kraken's key is
not predictable ("XXBTZEUR", "XBTUSDT", ...) and reconstructing it is a
guess. An error payload deliberately does not populate the cache, so an
unknown pair retries rather than serving an empty series for six hours.

Neither provider is allowed to raise. A rate that cannot be established
returns None, and the caller must treat that as "do not pay" — the one
outcome this module must never produce is a plausible-looking wrong number.
sats_for rounds rather than truncates, because always rounding down would
quietly shortchange the employee over the life of a contract.

Tests stub the HTTP layer rather than the function under test, so the real
cache path runs — an earlier version stubbed _kraken_series and
re-implemented the caching in the test, which would have passed even with
the cache deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
Padreug 2026-08-31 21:39:18 +02:00
commit aed31f4b70
2 changed files with 381 additions and 0 deletions

241
tests/test_rates.py Normal file
View file

@ -0,0 +1,241 @@
"""Historical rate lookup.
The network is stubbed — what these pin down is the fallback order, the
caching that keeps a backfill to one request, and above all that an
unknowable rate comes back as None rather than as some other number.
"""
import asyncio
from datetime import date, datetime, timedelta, timezone
import pytest
from .. import rates
from ..rates import historical_btc_rate, sats_for
@pytest.fixture(autouse=True)
def _clear_cache():
rates._series_cache.clear()
yield
rates._series_cache.clear()
class Recorder:
def __init__(self):
self.kraken_calls = 0
self.coingecko_calls = 0
@pytest.fixture
def stub(monkeypatch):
rec = Recorder()
async def fake_kraken(currency):
rec.kraken_calls += 1
if currency != "EUR":
return {} # no such pair
return {date(2026, 5, 15): 69743.26, date(2026, 5, 16): 70120.0}
async def fake_coingecko(day, currency):
rec.coingecko_calls += 1
return 61000.0 if currency == "GBP" else None
monkeypatch.setattr(rates, "_kraken_series", fake_kraken)
monkeypatch.setattr(rates, "_coingecko_rate", fake_coingecko)
return rec
# --- sourcing --------------------------------------------------------------
def test_kraken_answers_without_touching_the_fallback(stub):
rate = asyncio.run(historical_btc_rate(date(2026, 5, 15), "EUR"))
assert rate == 69743.26
assert (stub.kraken_calls, stub.coingecko_calls) == (1, 0)
def test_currency_kraken_does_not_quote_falls_through(stub):
rate = asyncio.run(historical_btc_rate(date(2026, 5, 15), "GBP"))
assert rate == 61000.0
assert stub.coingecko_calls == 1
def test_date_outside_krakens_range_falls_through(stub):
"""Kraken keeps ~2 years; an older payday has to go to CoinGecko."""
asyncio.run(historical_btc_rate(date(2019, 1, 1), "EUR"))
assert stub.coingecko_calls == 1
def test_currency_is_case_insensitive(stub):
assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "eur")) == 69743.26
def test_sat_contracts_are_a_programming_error():
with pytest.raises(ValueError):
asyncio.run(historical_btc_rate(date(2026, 5, 15), "sat"))
# --- the answer that must never be wrong -----------------------------------
def test_unknowable_rate_returns_none_not_a_guess(stub):
"""Neither source has it: the caller must get None so it can refuse to
pay, rather than a plausible number from the wrong day."""
assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "JPY")) is None
def test_a_failing_provider_does_not_propagate(monkeypatch, stub):
async def boom(_currency):
raise RuntimeError("kraken is down")
monkeypatch.setattr(rates, "_kraken_series", boom)
# GBP still resolves via the fallback despite the primary throwing.
assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "GBP")) == 61000.0
def test_both_providers_failing_returns_none(monkeypatch, stub):
async def boom(*_args):
raise RuntimeError("offline")
monkeypatch.setattr(rates, "_kraken_series", boom)
monkeypatch.setattr(rates, "_coingecko_rate", boom)
assert asyncio.run(historical_btc_rate(date(2026, 5, 15), "EUR")) is None
# --- caching ---------------------------------------------------------------
# These stub the HTTP layer rather than `_kraken_series`, so the real cache
# path runs. Stubbing the function under test would make them pass even if
# the cache were deleted.
def _kraken_payload(days: int = 31) -> dict:
base = datetime(2026, 5, 1, tzinfo=timezone.utc)
candles = [
[int((base + timedelta(days=i)).timestamp()), "0", "0", "0", f"{70000 + i}"]
for i in range(days)
]
return {"error": [], "result": {"XXBTZEUR": candles, "last": 0}}
@pytest.fixture
def http_stub(monkeypatch):
"""Replace httpx.AsyncClient with one that counts requests."""
calls = {"n": 0}
class FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
pass
def json(self):
return self._payload
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
async def get(self, _url, params=None, timeout=None):
calls["n"] += 1
return FakeResponse(_kraken_payload())
monkeypatch.setattr(rates.httpx, "AsyncClient", FakeClient)
return calls
def test_a_backfill_costs_one_request(http_stub):
"""Twelve periods, one series fetch — the whole reason Kraken is primary
rather than the date-addressed API."""
async def run():
return [
await historical_btc_rate(date(2026, 5, 1) + timedelta(days=i), "EUR")
for i in range(12)
]
got = asyncio.run(run())
assert got == [70000.0 + i for i in range(12)]
assert http_stub["n"] == 1
def test_the_series_is_parsed_from_the_positional_key(http_stub):
"""Kraken's result key is unpredictable ("XXBTZEUR", "XBTUSDT", ...), so
it is read positionally rather than reconstructed."""
assert asyncio.run(historical_btc_rate(date(2026, 5, 3), "EUR")) == 70002.0
def test_a_stale_cache_entry_is_refetched(http_stub):
asyncio.run(historical_btc_rate(date(2026, 5, 1), "EUR"))
assert http_stub["n"] == 1
fetched_at, series = rates._series_cache["EUR"]
rates._series_cache["EUR"] = (
fetched_at - rates._CACHE_TTL - timedelta(minutes=1),
series,
)
asyncio.run(historical_btc_rate(date(2026, 5, 1), "EUR"))
assert http_stub["n"] == 2
def test_a_kraken_error_payload_is_not_cached(monkeypatch):
"""An unknown pair must not poison the cache — the next call should try
again rather than serve an empty series for six hours."""
calls = {"n": 0}
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
return {"error": ["EQuery:Unknown asset pair"], "result": {}}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_exc):
return False
async def get(self, _url, params=None, timeout=None):
calls["n"] += 1
return FakeResponse()
monkeypatch.setattr(rates.httpx, "AsyncClient", FakeClient)
async def no_fallback(_day, _currency):
return None
monkeypatch.setattr(rates, "_coingecko_rate", no_fallback)
asyncio.run(historical_btc_rate(date(2026, 5, 1), "XYZ"))
asyncio.run(historical_btc_rate(date(2026, 5, 1), "XYZ"))
assert calls["n"] == 2
assert "XYZ" not in rates._series_cache
# --- conversion ------------------------------------------------------------
def test_sats_for_converts_at_the_given_rate():
# 10 EUR at 69743.26 EUR/BTC
assert sats_for(10, 69743.26) == round(10 / 69743.26 * 1e8)
def test_sats_for_rounds_rather_than_truncates():
"""Truncating every payday would quietly shortchange the employee over
the life of a contract."""
assert sats_for(1, 3e8) == 0 # 0.333 sat -> 0
assert sats_for(2, 3e8) == 1 # 0.667 sat -> 1, not 0
def test_sats_for_rejects_a_nonsense_rate():
for bad in (0, -1):
with pytest.raises(ValueError):
sats_for(10, bad)