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)