Convert all currencies to CRC and poll rates every 6h
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s

Budget/transactions/salarios totals summed Transaction.amount directly,
so USD/EUR entries were treated as CRC and effectively disappeared from
the dashboard (the analytics fix in 9a80f2a only covered analytics).
Adds a shared get_converted_amount_expr() helper driven by the full
Currency enum — USD/EUR via ExchangeRate-API, BTC/XMR via CoinGecko —
and wires it into every func.sum(Transaction.amount) site.

Also starts a background task in the FastAPI lifespan that force-refreshes
every currency 4x/day, persisting USD to the DB and updating in-memory
caches for the rest. Failures are swallowed per-currency so a CoinGecko
outage cannot take out USD/EUR, and the last-known rate is always retained.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-15 17:16:20 -06:00
parent 9a80f2a997
commit 94a8a894a6
6 changed files with 222 additions and 75 deletions

View File

@@ -10,7 +10,7 @@ from app.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.models.models import Category, Transaction from app.models.models import Category, Transaction
from app.services.budget_projection import get_cycle_range from app.services.budget_projection import get_cycle_range
from app.services.exchange_rate import get_current_rate, get_eur_crc_rate from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/analytics", tags=["analytics"]) router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -38,17 +38,6 @@ class DailySpending(BaseModel):
count: int count: int
def _get_crc_multipliers(session: Session) -> dict[str, float]:
"""Return multipliers to convert each currency to CRC."""
usd_rate = get_current_rate(session)
eur_rate = get_eur_crc_rate()
return {
"CRC": 1.0,
"USD": usd_rate.sell_rate if usd_rate else 0.0,
"EUR": eur_rate if eur_rate else 0.0,
}
@router.get("/by-category", response_model=list[CategorySpending]) @router.get("/by-category", response_model=list[CategorySpending])
def spending_by_category( def spending_by_category(
cycle_year: Optional[int] = None, cycle_year: Optional[int] = None,
@@ -56,18 +45,12 @@ def spending_by_category(
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
): ):
rates = _get_crc_multipliers(session) amount_crc = get_converted_amount_expr(session)
query = ( query = (
select( select(
Transaction.category_id, Transaction.category_id,
func.sum( func.sum(amount_crc).label("total"),
case(
(Transaction.currency == "USD", Transaction.amount * rates["USD"]),
(Transaction.currency == "EUR", Transaction.amount * rates["EUR"]),
else_=Transaction.amount,
)
).label("total"),
func.count().label("count"), func.count().label("count"),
) )
.where(Transaction.transaction_type == "COMPRA") .where(Transaction.transaction_type == "COMPRA")
@@ -113,7 +96,7 @@ def monthly_trend(
total_crc includes all currencies converted to CRC at current rates. total_crc includes all currencies converted to CRC at current rates.
total_usd is the raw USD amount (unconverted) for display purposes. total_usd is the raw USD amount (unconverted) for display purposes.
""" """
rates = _get_crc_multipliers(session) amount_crc = get_converted_amount_expr(session)
now = datetime.now() now = datetime.now()
results = [] results = []
month_names = [ month_names = [
@@ -128,16 +111,7 @@ def monthly_trend(
row = session.exec( row = session.exec(
select( select(
func.count(), func.count(),
func.coalesce( func.coalesce(func.sum(amount_crc), 0),
func.sum(
case(
(Transaction.currency == "USD", Transaction.amount * rates["USD"]),
(Transaction.currency == "EUR", Transaction.amount * rates["EUR"]),
else_=Transaction.amount,
)
),
0,
),
func.coalesce( func.coalesce(
func.sum( func.sum(
case( case(
@@ -189,18 +163,12 @@ def daily_spending(
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
): ):
rates = _get_crc_multipliers(session) amount_crc = get_converted_amount_expr(session)
query = ( query = (
select( select(
func.date(Transaction.date).label("day"), func.date(Transaction.date).label("day"),
func.sum( func.sum(amount_crc).label("total"),
case(
(Transaction.currency == "USD", Transaction.amount * rates["USD"]),
(Transaction.currency == "EUR", Transaction.amount * rates["EUR"]),
else_=Transaction.amount,
)
).label("total"),
func.count().label("count"), func.count().label("count"),
) )
.where(Transaction.transaction_type == "COMPRA") .where(Transaction.transaction_type == "COMPRA")

View File

@@ -8,6 +8,7 @@ from sqlmodel import Session, col, func, select
from app.auth import get_current_user from app.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.models.models import Transaction, TransactionRead, TransactionType from app.models.models import Transaction, TransactionRead, TransactionType
from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/salarios", tags=["salarios"]) router = APIRouter(prefix="/salarios", tags=["salarios"])
@@ -40,10 +41,11 @@ def salarios_summary(
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
): ):
amount_crc = get_converted_amount_expr(session)
result = session.exec( result = session.exec(
select( select(
func.count(), func.count(),
func.coalesce(func.sum(Transaction.amount), 0), func.coalesce(func.sum(amount_crc), 0),
func.max(Transaction.date), func.max(Transaction.date),
).where(Transaction.transaction_type == TransactionType.DEPOSITO) ).where(Transaction.transaction_type == TransactionType.DEPOSITO)
).first() ).first()

View File

@@ -20,6 +20,7 @@ from app.models.models import (
) )
from app.services.budget_projection import get_cycle_range, get_previous_cycle from app.services.budget_projection import get_cycle_range, get_previous_cycle
from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/transactions", tags=["transactions"]) router = APIRouter(prefix="/transactions", tags=["transactions"])
@@ -110,6 +111,7 @@ def list_billing_cycles(
return [] return []
min_date, max_date = result min_date, max_date = result
amount_crc = get_converted_amount_expr(session)
cycles = [] cycles = []
# Determine which cycle the min_date falls into # Determine which cycle the min_date falls into
@@ -129,7 +131,7 @@ def list_billing_cycles(
# Count transactions in this cycle # Count transactions in this cycle
count_result = session.exec( count_result = session.exec(
select(func.count(), func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start, Transaction.date < end Transaction.date >= start, Transaction.date < end
) )
).first() ).first()

View File

@@ -1,3 +1,4 @@
import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
@@ -7,6 +8,7 @@ from app.api.v1.router import api_router
from app.config import settings from app.config import settings
from app.db import init_db, run_migrations from app.db import init_db, run_migrations
from app.seed import seed_db from app.seed import seed_db
from app.services.exchange_rate import refresh_rates_periodically
@asynccontextmanager @asynccontextmanager
@@ -14,7 +16,15 @@ async def lifespan(app: FastAPI):
init_db() init_db()
run_migrations() run_migrations()
seed_db() seed_db()
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
try:
yield yield
finally:
rate_refresh_task.cancel()
try:
await rate_refresh_task
except asyncio.CancelledError:
pass
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan) app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)

View File

@@ -12,6 +12,7 @@ from app.models.models import (
TransactionSource, TransactionSource,
TransactionType, TransactionType,
) )
from app.services.exchange_rate import get_converted_amount_expr
MIN_YEAR = 2026 MIN_YEAR = 2026
MAX_YEAR = 2030 MAX_YEAR = 2030
@@ -104,13 +105,15 @@ def compute_actuals_by_source(
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m) prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month) cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
results = {} results = {}
for source in TransactionSource: for source in TransactionSource:
if source == TransactionSource.CREDIT_CARD: if source == TransactionSource.CREDIT_CARD:
start, end = cc_start, cc_end start, end = cc_start, cc_end
# Normal transactions in this cycle (not deferred) # Normal transactions in this cycle (not deferred)
compra_normal = session.exec( compra_normal = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start, Transaction.date >= start,
Transaction.date < end, Transaction.date < end,
Transaction.source == source, Transaction.source == source,
@@ -120,7 +123,7 @@ def compute_actuals_by_source(
).one() ).one()
# Deferred from previous cycle # Deferred from previous cycle
compra_deferred = session.exec( compra_deferred = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start, Transaction.date >= prev_start,
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == source, Transaction.source == source,
@@ -131,7 +134,7 @@ def compute_actuals_by_source(
compra = float(compra_normal) + float(compra_deferred) compra = float(compra_normal) + float(compra_deferred)
dev_normal = session.exec( dev_normal = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start, Transaction.date >= start,
Transaction.date < end, Transaction.date < end,
Transaction.source == source, Transaction.source == source,
@@ -140,7 +143,7 @@ def compute_actuals_by_source(
) )
).one() ).one()
dev_deferred = session.exec( dev_deferred = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start, Transaction.date >= prev_start,
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == source, Transaction.source == source,
@@ -180,7 +183,7 @@ def compute_actuals_by_source(
else: else:
# Cash / Transfer: calendar month, no deferred logic # Cash / Transfer: calendar month, no deferred logic
compra = session.exec( compra = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start, Transaction.date >= cal_start,
Transaction.date < cal_end, Transaction.date < cal_end,
Transaction.source == source, Transaction.source == source,
@@ -188,7 +191,7 @@ def compute_actuals_by_source(
) )
).one() ).one()
devolucion = session.exec( devolucion = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start, Transaction.date >= cal_start,
Transaction.date < cal_end, Transaction.date < cal_end,
Transaction.source == source, Transaction.source == source,
@@ -230,6 +233,8 @@ def compute_actuals_by_category(
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m) prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month) cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
totals: dict[int, float] = {} totals: dict[int, float] = {}
def _merge_rows(rows: list) -> None: def _merge_rows(rows: list) -> None:
@@ -245,7 +250,7 @@ def compute_actuals_by_category(
select( select(
Transaction.category_id, Transaction.category_id,
Transaction.transaction_type, Transaction.transaction_type,
func.sum(Transaction.amount), func.sum(amount_crc),
) )
.where( .where(
Transaction.date >= cc_start, Transaction.date >= cc_start,
@@ -265,7 +270,7 @@ def compute_actuals_by_category(
select( select(
Transaction.category_id, Transaction.category_id,
Transaction.transaction_type, Transaction.transaction_type,
func.sum(Transaction.amount), func.sum(amount_crc),
) )
.where( .where(
Transaction.date >= prev_start, Transaction.date >= prev_start,
@@ -285,7 +290,7 @@ def compute_actuals_by_category(
select( select(
Transaction.category_id, Transaction.category_id,
Transaction.transaction_type, Transaction.transaction_type,
func.sum(Transaction.amount), func.sum(amount_crc),
) )
.where( .where(
Transaction.date >= cal_start, Transaction.date >= cal_start,
@@ -310,6 +315,8 @@ def compute_cc_by_category(
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m) prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m) prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
amount_crc = get_converted_amount_expr(session)
totals: dict[int | None, float] = {} totals: dict[int | None, float] = {}
def _merge(rows: list) -> None: def _merge(rows: list) -> None:
@@ -325,7 +332,7 @@ def compute_cc_by_category(
select( select(
Transaction.category_id, Transaction.category_id,
Transaction.transaction_type, Transaction.transaction_type,
func.sum(Transaction.amount), func.sum(amount_crc),
) )
.where( .where(
Transaction.date >= cc_start, Transaction.date >= cc_start,
@@ -343,7 +350,7 @@ def compute_cc_by_category(
select( select(
Transaction.category_id, Transaction.category_id,
Transaction.transaction_type, Transaction.transaction_type,
func.sum(Transaction.amount), func.sum(amount_crc),
) )
.where( .where(
Transaction.date >= prev_start, Transaction.date >= prev_start,
@@ -449,6 +456,8 @@ def compute_monthly_projection(
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m) prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month) cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
def _sum_uncategorized(rows: list) -> float: def _sum_uncategorized(rows: list) -> float:
total = 0.0 total = 0.0
for tx_type, amount in rows: for tx_type, amount in rows:
@@ -461,7 +470,7 @@ def compute_monthly_projection(
# CC uncategorized: this cycle (not deferred) # CC uncategorized: this cycle (not deferred)
uncovered_actual += _sum_uncategorized( uncovered_actual += _sum_uncategorized(
session.exec( session.exec(
select(Transaction.transaction_type, func.sum(Transaction.amount)) select(Transaction.transaction_type, func.sum(amount_crc))
.where( .where(
Transaction.date >= cc_start, Transaction.date >= cc_start,
Transaction.date < cc_end, Transaction.date < cc_end,
@@ -476,7 +485,7 @@ def compute_monthly_projection(
# CC uncategorized: deferred from previous cycle # CC uncategorized: deferred from previous cycle
uncovered_actual += _sum_uncategorized( uncovered_actual += _sum_uncategorized(
session.exec( session.exec(
select(Transaction.transaction_type, func.sum(Transaction.amount)) select(Transaction.transaction_type, func.sum(amount_crc))
.where( .where(
Transaction.date >= prev_start, Transaction.date >= prev_start,
Transaction.date < prev_end, Transaction.date < prev_end,
@@ -491,7 +500,7 @@ def compute_monthly_projection(
# Non-CC uncategorized: calendar month # Non-CC uncategorized: calendar month
uncovered_actual += _sum_uncategorized( uncovered_actual += _sum_uncategorized(
session.exec( session.exec(
select(Transaction.transaction_type, func.sum(Transaction.amount)) select(Transaction.transaction_type, func.sum(amount_crc))
.where( .where(
Transaction.date >= cal_start, Transaction.date >= cal_start,
Transaction.date < cal_end, Transaction.date < cal_end,

View File

@@ -1,12 +1,21 @@
import asyncio
import logging
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timedelta from datetime import datetime, timedelta
import httpx import httpx
from sqlalchemy import case
from sqlmodel import Session, col, select from sqlmodel import Session, col, select
from app.config import settings from app.config import settings
from app.db import engine
from app.models.models import ExchangeRate from app.models.models import ExchangeRate
logger = logging.getLogger(__name__)
# Scheduled refresh interval — 4x/day
REFRESH_INTERVAL_SECONDS = 6 * 3600
# BCCR indicators: 317 = buy, 318 = sell # BCCR indicators: 317 = buy, 318 = sell
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos" BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos"
@@ -23,9 +32,13 @@ _cache: dict[str, tuple[ExchangeRate, datetime]] = {}
_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate _last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
CACHE_TTL = timedelta(hours=1) CACHE_TTL = timedelta(hours=1)
# EUR/CRC mid-market rate cache # Generic X/CRC mid-market rate cache (by currency code)
_eur_crc_cache: dict[str, tuple[float, datetime]] = {} _xcrc_cache: dict[str, tuple[float, datetime]] = {}
_last_known_eur_crc: float | None = None _last_known_xcrc: dict[str, float] = {}
# CoinGecko ids for supported crypto codes
_COINGECKO_IDS = {"BTC": "bitcoin", "XMR": "monero"}
COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price"
def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None: def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
@@ -171,10 +184,10 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
return None return None
def _fetch_eur_crc_mid() -> float | None: def _fetch_fiat_crc_mid(code: str) -> float | None:
"""Derive EUR/CRC mid-market rate from ExchangeRate-API (USD-based). """Derive {code}/CRC mid-market rate from ExchangeRate-API (USD-based).
EUR/CRC = CRC_per_USD / EUR_per_USD X/CRC = CRC_per_USD / X_per_USD
""" """
try: try:
resp = httpx.get(EXCHANGERATE_API_URL, timeout=10) resp = httpx.get(EXCHANGERATE_API_URL, timeout=10)
@@ -182,32 +195,175 @@ def _fetch_eur_crc_mid() -> float | None:
data = resp.json() data = resp.json()
if data.get("result") == "success": if data.get("result") == "success":
crc = data["rates"].get("CRC") crc = data["rates"].get("CRC")
eur = data["rates"].get("EUR") x = data["rates"].get(code)
if crc and eur: if crc and x:
return float(crc) / float(eur) return float(crc) / float(x)
except Exception: except Exception:
pass pass
return None return None
def get_eur_crc_rate() -> float | None: def _fetch_crypto_crc(code: str) -> float | None:
"""Get current EUR→CRC mid-market rate (cached 1 hour).""" """Fetch {code}/CRC spot from CoinGecko."""
global _last_known_eur_crc coin_id = _COINGECKO_IDS.get(code)
if not coin_id:
return None
try:
resp = httpx.get(
COINGECKO_URL,
params={"ids": coin_id, "vs_currencies": "crc"},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
price = data.get(coin_id, {}).get("crc")
if price:
return float(price)
except Exception:
pass
return None
cached = _eur_crc_cache.get("current")
def get_crc_rate(code: str) -> float | None:
"""Get current {code}→CRC rate (cached 1 hour). Fiat via ExchangeRate-API, crypto via CoinGecko."""
if code == "CRC":
return 1.0
cached = _xcrc_cache.get(code)
if cached and datetime.utcnow() - cached[1] < CACHE_TTL: if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
return cached[0] return cached[0]
rate = _fetch_eur_crc_mid() if code in _COINGECKO_IDS:
rate = _fetch_crypto_crc(code)
else:
rate = _fetch_fiat_crc_mid(code)
if rate is not None: if rate is not None:
_eur_crc_cache["current"] = (rate, datetime.utcnow()) _xcrc_cache[code] = (rate, datetime.utcnow())
_last_known_eur_crc = rate _last_known_xcrc[code] = rate
return rate return rate
if _last_known_eur_crc: return _last_known_xcrc.get(code)
return _last_known_eur_crc
return None
def get_crc_multipliers(session: Session) -> dict[str, float]:
"""Return {currency_code: CRC_multiplier} for every supported currency."""
from app.models.models import Currency
multipliers: dict[str, float] = {"CRC": 1.0}
usd_rate = get_current_rate(session)
if usd_rate:
multipliers["USD"] = usd_rate.sell_rate
for code in (c.value for c in Currency):
if code in multipliers:
continue
rate = get_crc_rate(code)
if rate is not None:
multipliers[code] = rate
return multipliers
def get_converted_amount_expr(session: Session):
"""Return a SQLAlchemy expression converting Transaction.amount to CRC.
Builds a CASE that multiplies by the per-currency CRC rate; CRC passes through.
Missing rates fall back to 1.0 (treat as CRC) rather than 0.0 so a transient
API outage does not silently zero out foreign-currency totals.
"""
from app.models.models import Transaction
multipliers = get_crc_multipliers(session)
whens = [
(Transaction.currency == code, Transaction.amount * mult)
for code, mult in multipliers.items()
if code != "CRC"
]
if not whens:
return Transaction.amount
return case(*whens, else_=Transaction.amount)
def _refresh_usd_rate() -> bool:
"""Force-fetch USD/CRC from APIs and persist to DB. Returns True on success."""
fetched = _fetch_rate_from_apis()
if fetched is None:
return False
buy, sell = fetched
with Session(engine) as session:
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
session.add(rate)
session.commit()
session.refresh(rate)
_remember(rate)
return True
def _refresh_other_rate(code: str) -> bool:
"""Force-fetch {code}/CRC and update in-memory cache. Returns True on success."""
if code in _COINGECKO_IDS:
rate = _fetch_crypto_crc(code)
else:
rate = _fetch_fiat_crc_mid(code)
if rate is None:
return False
_xcrc_cache[code] = (rate, datetime.utcnow())
_last_known_xcrc[code] = rate
return True
def refresh_all_rates() -> dict[str, bool]:
"""Force-refresh every supported currency.
Each currency is refreshed independently — one failure does not affect others.
On success the DB (for USD) and in-memory caches are updated. On failure the
previous value is retained via `_last_known_*` / stale-DB fallback, so callers
always see the most recent working rate.
"""
from app.models.models import Currency
results: dict[str, bool] = {}
try:
results["USD"] = _refresh_usd_rate()
except Exception:
logger.exception("USD rate refresh failed")
results["USD"] = False
for currency in Currency:
code = currency.value
if code in ("CRC", "USD"):
continue
try:
results[code] = _refresh_other_rate(code)
except Exception:
logger.exception("%s rate refresh failed", code)
results[code] = False
return results
async def refresh_rates_periodically(
interval_seconds: int = REFRESH_INTERVAL_SECONDS,
) -> None:
"""Background loop that refreshes all currency rates every `interval_seconds`.
Never raises — failures are logged and the last-known rates are retained.
Runs one refresh immediately on startup, then sleeps on the fixed interval.
"""
while True:
try:
report = await asyncio.to_thread(refresh_all_rates)
ok = sorted(k for k, v in report.items() if v)
failed = sorted(k for k, v in report.items() if not v)
logger.info(
"Exchange rate refresh complete: ok=%s failed=%s", ok, failed
)
except Exception:
logger.exception("Exchange rate refresh loop crashed")
await asyncio.sleep(interval_seconds)
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]: def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]: