From aed31f4b704a73ee33e20f776fa4f66c4cbc05a0 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 21:39:18 +0200 Subject: [PATCH] feat: historical BTC rate lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- rates.py | 140 +++++++++++++++++++++++++ tests/test_rates.py | 241 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 rates.py create mode 100644 tests/test_rates.py diff --git a/rates.py b/rates.py new file mode 100644 index 0000000..a0a9910 --- /dev/null +++ b/rates.py @@ -0,0 +1,140 @@ +"""Historical BTC rates, for pricing a payout at the day it was due. + +LNbits cannot answer this. 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 +(`lnbits_default_accounting_currency`). Useful for a monitoring graph; +useless for "what was BTC worth on the 15th". + +So payroll asks a date-aware API directly. + +**Kraken first.** One `OHLC?interval=1440` request returns ~720 daily +candles, so a twelve-period backfill costs a single call and every date is +served from the parsed series. It is also already one of the providers +LNbits itself trusts. + +**CoinGecko second**, for currencies Kraken lists no pair for. It is +addressed by date rather than by series, so it costs one request per date — +fine as a fallback, bad as a default against free-tier rate limits. + +Both return None rather than raising. A rate that cannot be established is +a payout that must not happen: the caller records a failed period and lets +a human decide, which is the same thing an underfunded wallet does. The one +outcome this module must never produce is a plausible-looking wrong number. +""" + +from datetime import date, datetime, timedelta, timezone + +import httpx +from loguru import logger + +# Kraken quotes bitcoin as XBT. Its response key is *not* predictable +# ("XXBTZEUR", "XBTUSDT", …), so the series is read positionally instead of +# by reconstructing the name. +_KRAKEN_URL = "https://api.kraken.com/0/public/OHLC" +_COINGECKO_URL = "https://api.coingecko.com/api/v3/coins/bitcoin/history" + +_TIMEOUT = 15.0 + +# A daily series is only appended to once a day, so re-fetching more often +# than this buys nothing. Keeps a backfill plus every retry to one request. +_CACHE_TTL = timedelta(hours=6) + +# currency -> (fetched_at, {day: close}) +_series_cache: dict[str, tuple[datetime, dict[date, float]]] = {} + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +async def _kraken_series(currency: str) -> dict[date, float]: + """Daily closes for XBT/, keyed by UTC day. Empty if the pair + is unknown or the request fails.""" + cached = _series_cache.get(currency) + if cached and _now() - cached[0] < _CACHE_TTL: + return cached[1] + + params = {"pair": f"XBT{currency}", "interval": "1440"} + async with httpx.AsyncClient() as client: + response = await client.get(_KRAKEN_URL, params=params, timeout=_TIMEOUT) + response.raise_for_status() + payload = response.json() + + if payload.get("error"): + # Unknown pair is the ordinary case here, not an incident — it just + # means this currency needs the fallback. + logger.debug(f"payroll: kraken has no XBT{currency} series: {payload['error']}") + return {} + + result = payload.get("result", {}) + keys = [k for k in result if k != "last"] + if not keys: + return {} + + series = { + datetime.fromtimestamp(candle[0], tz=timezone.utc).date(): float(candle[4]) + for candle in result[keys[0]] + } + _series_cache[currency] = (_now(), series) + return series + + +async def _coingecko_rate(day: date, currency: str) -> float | None: + """Close for one specific day. One request per date — fallback only.""" + params = {"date": day.strftime("%d-%m-%Y"), "localization": "false"} + async with httpx.AsyncClient() as client: + response = await client.get(_COINGECKO_URL, params=params, timeout=_TIMEOUT) + response.raise_for_status() + payload = response.json() + + price = payload.get("market_data", {}).get("current_price", {}) + value = price.get(currency.lower()) + return float(value) if value else None + + +async def historical_btc_rate(day: date, currency: str) -> float | None: + """Units of `currency` per whole BTC on `day`, or None if unknowable. + + None is a legitimate answer — the date may predate the provider's + history, the currency may not be quoted anywhere, or the API may be + down. Callers must treat it as "do not pay", never as "use some other + number". + """ + currency = currency.upper() + if currency == "SAT": + raise ValueError("sat-denominated contracts need no rate") + + try: + series = await _kraken_series(currency) + if day in series: + return series[day] + if series: + logger.debug( + f"payroll: {day} outside kraken's XBT{currency} range " + f"({min(series)}..{max(series)}), trying coingecko" + ) + except Exception as exc: # network, malformed payload, anything + logger.warning(f"payroll: kraken rate lookup failed for {currency}: {exc}") + + try: + rate = await _coingecko_rate(day, currency) + if rate: + return rate + except Exception as exc: + logger.warning(f"payroll: coingecko rate lookup failed for {currency}: {exc}") + + logger.warning(f"payroll: no historical rate for {currency} on {day}") + return None + + +def sats_for(amount: float, rate: float) -> int: + """`amount` of fiat in sats, at `rate` units-per-BTC. + + Rounded to the nearest sat rather than truncated: over a year of + paydays, always rounding down would quietly shortchange the employee. + """ + if rate <= 0: + raise ValueError("rate must be positive") + return round(amount / rate * 100_000_000) diff --git a/tests/test_rates.py b/tests/test_rates.py new file mode 100644 index 0000000..e17eb9a --- /dev/null +++ b/tests/test_rates.py @@ -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)