Ran the real lookup against both live APIs rather than the stubs. Three findings worth being written down instead of rediscovered: - Kraken returns ~721 daily candles, so ~2 years for any pair it quotes. EUR and GBP both resolve from it directly; the fallback never fires for them. - CoinGecko's free tier answers within 365 days and returns 401 Unauthorized beyond it — a plan limit wearing an auth error's clothes, not a transient failure. The practical ceiling is therefore ~2 years for major pairs and 1 year for anything else, which matters given the ask was "historical data for up to even a year". - The two sources disagree by ~2.5% on the same date (2026-05-15: Kraken 68,047.50 EUR/BTC, CoinGecko 69,743.26) — one exchange's daily close versus a cross-exchange average. Neither is wrong, which is the reason the ledger records rate_source beside rate rather than presenting a bare figure as canonical. Refusal behaviour confirmed live: an unknown pair and a 2019 date both come back None rather than falling through to some other number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
154 lines
6.1 KiB
Python
154 lines
6.1 KiB
Python
"""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.
|
|
|
|
Verified coverage (measured against both live APIs, 2026-08-31):
|
|
|
|
* Kraken returns ~721 daily candles, so **roughly two years** back for any
|
|
pair it quotes. EUR and GBP both resolve from it directly.
|
|
* CoinGecko's free tier answers within **365 days** and returns `401
|
|
Unauthorized` beyond that — a plan limit dressed as an auth error, not a
|
|
transient failure. So dates older than ~2 years are unreachable for
|
|
everything, and older than a year for currencies Kraken does not quote.
|
|
* The two disagree: for 2026-05-15 Kraken gave 68,047.50 EUR/BTC and
|
|
CoinGecko 69,743.26, about 2.5% apart — one exchange's daily close versus
|
|
a cross-exchange volume-weighted average. Neither is wrong, which is
|
|
exactly why the payout records `rate_source` next to `rate` rather than
|
|
presenting a bare number as though it were canonical.
|
|
|
|
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/<currency>, 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)
|