payroll/rates.py
Padreug 2e6009b612 docs: record the measured reach of the rate lookup
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
2026-08-31 21:44:21 +02:00

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)