Forex Rates Feature Design¶
Table of contents¶
- Overview
- Terminology
- Requirements
- API service selection
- Data structure and storage
- Caching and TTL strategy
- Fallback mechanism
- Integration points
- Architecture
- Error handling
- Configuration
- Appendix: Alternative API providers
Overview¶
The forex rates feature enables the HomeBudget wrapper to fetch current foreign exchange rates for non-base currency transactions. This feature supports the existing foreign currency input rules by providing automatic rate lookups and caching to handle offline scenarios and reduce API calls.
Use case¶
When users record expenses or income in foreign currencies, for example: USD amounts in a SGD-base-currency account, they need to convert between currencies using current exchange rates. Instead of manually entering rates, this feature:
- Fetches current rates from a public API
- Caches rates locally with timestamps
- Serves cached rates within an hour without additional API calls
- Falls back to 1.0 when offline or data is unavailable
Concept¶
The primary conversion path leverages USD as an intermediary:
- Fetch rates for all currencies against USD
- Calculate conversion between any two currencies using the formula:
CURRENCY_A.USD / CURRENCY_B.USD - Example: To convert SGD to USD, use the quoted rate directly. To convert SGD to EUR, calculate the EUR and SGD rates against USD
Terminology¶
- Base currency: The primary currency of the user's budget (e.g., SGD)
- Reference currency: USD, used as a common denominator for all exchange rates fetched from the API. Referred to as "base" in API responses but distinct from the user's base currency.
- Foreign currency: Any currency other than the base currency (e.g., USD, EUR)
- Forex rate: also "exchange rate". The exchange rate between a foreign currency and the base currency, expressed as a decimal (e.g., 1 USD = 1.35 SGD → rate = 1.35)
- Cache TTL: Time-to-live for cached rates, after which they are considered stale and a fresh API fetch is triggered
- Fallback rate: The default rate of 1.0 used when no valid rate is available, meaning no conversion is applied
Requirements¶
Functional requirements¶
- Fetch rates on demand: Users can request current forex rates for a given currency pair
- Cache locally: Store fetched rates in JSON with last-fetched timestamp
- TTL enforcement: Use cached rates if fetched within the last hour
- Fallback behavior: Return 1.0 if:
- Application is offline (cannot reach API)
- No rate has been previously cached for a currency
-
Rate fetch fails with an error
-
Calculate derived rates: Support arbitrary currency pairs by using USD as a hub
- Configuration: Allow users to configure API preferences and cache location
Non-functional requirements¶
- Reliability: Never block user operations due to rate fetch failures
- Performance: Minimize API calls through effective caching
- Transparency: Log fetch attempts, cache hits/misses, and fallbacks
- Testability: Separate API logic, caching logic, and data persistence
API service selection¶
Candidate services¶
| Service | Free Tier | Auth Required | Data Freshness | Currencies | Uptime | Best For |
|---|---|---|---|---|---|---|
| ExchangeRate-API | 1.5K req/mo | ❌ No | Daily | 161 | 99.99% | ✅ Recommended |
| exchangerate.host | 100 req/mo | ✅ Yes | Hourly | 168 | 99.9% | Limited free |
| Open Exchange Rates | 1.0K req/mo | ✅ Yes | Variable | 200+ | Claimed | Backup only |
Recommended choice: ExchangeRate-API¶
Rationale:
- No API key required — free tier works without authentication or credit card
- Simple REST API:
https://api.exchangerate-api.com/v4/latest/{currency} - 1,500 requests/month free (50/day, adequate with 1-hour cache TTL)
- Daily updates on free tier (sufficient for budget tracking)
- Covers all major currencies including SGD
- Returns rates as decimal values, no parsing required
- JSON responses with predictable structure
- 15+ years of service (since 2010), 99.99% uptime measured by Pingdom
- Used by hundreds of thousands of developers
Response structure:
{
"base": "USD",
"date": "2026-02-20",
"rates": {
"SGD": 1.35,
"EUR": 0.92,
"GBP": 0.79,
"JPY": 150.0,
...
}
}
Sample API requests¶
Get rates for USD (reference currency):
Response:
{
"base": "USD",
"date": "2026-02-20",
"rates": {
"SGD": 1.3502,
"EUR": 0.9187,
"GBP": 0.7925,
"JPY": 150.45,
...
}
}
Python example:
import requests
url = "https://api.exchangerate-api.com/v4/latest/USD"
response = requests.get(url, timeout=5)
data = response.json()
sgd_rate = data["rates"]["SGD"] # 1.3502
print(f"1 USD = {sgd_rate} SGD")
Configuration for API selection¶
Users need only specify cache TTL in hb-config.json:
This is optional; if omitted, defaults to 1-hour TTL. ExchangeRate-API is used automatically without API key or signup.
Data structure and storage¶
Cache file format¶
Store rates in JSON in a dedicated Forex directory:
File: {HomeBudgetData}/Forex/forex-rates.json
{
"metadata": {
"version": 1,
"last_update": "2026-02-20T15:30:45Z"
},
"timestamp": "2026-02-20T15:30:45Z",
"base": "USD",
"rates": {
"SGD": 1.3502,
"EUR": 0.9187,
"GBP": 0.7925,
"JPY": 150.45,
"AUD": 1.2708,
"CAD": 1.3550,
...
}
}
Purpose of structure¶
- metadata: Tracks cache version and last full update for diagnostics
- timestamp: Last fetch time; compared against current time for TTL validation
- base: Always
"USD"— all rates are denominated in USD - rates: Currency code → rate mapping for all currencies vs USD
- Single source of truth; any currency pair can be calculated via
RATES[A] / RATES[B] - Example: SGD to EUR =
RATES["SGD"] / RATES["EUR"]=1.3502 / 0.9187≈ 1.47
Caching and TTL strategy¶
TTL logic¶
When a user requests a rate for currency X:
- Check cache exists: Look for
rates[X]in the cache file - Check TTL validity:
- Parse
timestampfield - If
current_time - timestamp < 1 hour: Use cached rates -
Otherwise: Fetch fresh rates from API
-
Update on fetch: Store new timestamp and refreshed rates
- Persist to disk: Write updated cache file
Note: TTL is per-cache (all currencies share the same timestamp), not per-currency. Once any rate is stale, all rates are refreshed together.
TTL configuration¶
Default: 1 hour (cache_ttl_hours: 1)
Rationale:
- Balances minimizing API calls with reasonable rate freshness
- Typical daily forex volatility is 0.5-2%, acceptable for budget tracking
- Aligns with common transaction batch workflows (process daily receipts)
Users can override in config:
- Aggressive caching:
cache_ttl_hours: 24 - Conservative:
cache_ttl_hours: 0(always fetch, subject to API limits)
Cache initialization¶
On first use:
- If cache file doesn't exist, create it with empty structure
- First rate request triggers a fetch from the API
Fallback mechanism¶
Fallback scenarios¶
| Scenario | Behavior | Log Level |
|---|---|---|
| No cached rate exists | Attempt to fetch from API; fall back to 1.0 if fetch fails | INFO |
| API unreachable (connection error) | Return 1.0 | WARN |
| API returns HTTP error (5xx) | Return 1.0 | WARN |
| Rate is stale but no API available | Use stale rate | INFO |
| JSON parse error in cache | Clear entry, attempt fresh fetch; fall back to 1.0 if fetch fails | ERROR |
| Malformed API response | Return 1.0 | WARN |
Rate formula for fallback¶
The fallback rate 1.0 represents "no conversion" (unit rate), meaning the provided amount is used as-is without adjustment. This is appropriate because:
- In offline mode: User can manually correct the amount or rate later
- On first use: User can verify the amount and rate in the transaction review
- Preserves determinism: Same fallback value across all currency pairs, preventing confusion
Stale rate fallback¶
If cache exists but is older than TTL and API fails:
- Use the stale rate instead of 1.0
- Log at INFO level: "Cache expired but using stale rate due to API failure"
- Rationale: A day-old rate is better than no conversion
Integration points¶
Client API¶
The HomeBudgetClient exposes a helper method for other code to fetch rates if needed:
def get_forex_rate(
self,
from_currency: str,
to_currency: str | None = None
) -> float:
"""Fetch current forex rate for a given currency pair.
If to_currency is not specified, uses the account's base currency.
Returns 1.0 if rate is unavailable (offline or not cached).
Args:
from_currency: ISO 4217 currency code (e.g., 'SGD')
to_currency: Target currency code (optional, defaults to base currency)
Returns:
float: Exchange rate (fallback 1.0 if unavailable)
"""
Shared normalization layer¶
The forex manager is integrated into shared normalization methods that apply to all transaction types:
- For single updates:
_normalize_forex_inputs() - For batch operations:
_resolve_batch_forex_add() - For currency inference:
_infer_currency_for_*()methods
These methods are called by all transaction operations (add/update/delete) regardless of type (expense/income/transfer).
Handling amount-only input on non-base accounts¶
When a user provides only amount on a non-base currency account:
Without forex rate, rate=1.0:
- User enters:
amount=100on USD account (base currency is SGD) - Result:
amount=100, currency=USD, currency_amount=100, exchange_rate=1.0 - Interpretation: 100 USD = 100 SGD (incorrect 1:1 conversion)
With forex rate:
- User enters:
amount=100on USD account (amount is in USD, the account's currency) - Fetch rate: 1 USD = 1.35 SGD
- Calculate:
amount = 100 * 1.35 = 135 SGD(base currency) - Result:
amount=135, currency=USD, currency_amount=100, exchange_rate=1.35 - Interpretation: 100 USD ≈ 135 SGD at rate 1.35
Handling amount-only input on transfers (base to non-base)¶
When a user provides only amount on a transfer between base and non-base accounts:
Scenario:
- User on base account (SGD) transfers to non-base account (USD)
- User specifies:
amount=135(base currency, SGD) - Transfer involves both SGD (base, from_account) and USD (foreign, to_account)
Enhanced behavior:
- Infer non-base currency from target account:
currency=USD - Fetch rate: 1 USD = 1.35 SGD
- Calculate:
currency_amount = 135 / 1.35 = 100(WITHOUT rounding) - Send as foreign currency transaction:
amount=135, currency=USD, currency_amount=100, exchange_rate=1.35 - Interpretation: 100 USD ≈ 135 SGD at rate 1.35
This applies to the transfer's foreign currency leg (the non-base account side).
Architecture¶
Module structure
src/python/homebudget/
forex.py ← New module
client.py ← Integrate forex manager initialization
config.py ← Handle forex config section (optional)
Error handling¶
Normal flow with graceful fallback:
def get_rate(self, currency: str) -> float:
"""Get rate, falling back to 1.0 on any failure."""
# Check cache validity
if self._is_cache_valid():
rate = self._cache.get("rates", {}).get(currency)
if rate:
return float(rate)
# Cache miss or stale: try to fetch fresh
try:
all_rates = self._fetch_from_api(currency)
self._cache = {
"metadata": {"version": 1, "last_update": datetime.isoformat(datetime.utcnow())},
"timestamp": datetime.isoformat(datetime.utcnow()),
"base": "USD",
"rates": all_rates,
}
self._save_cache(self._cache)
rate = all_rates.get(currency)
if rate:
return float(rate)
except Exception as e:
# Try to use stale rate from cache
if self._cache and "rates" in self._cache:
rate = self._cache["rates"].get(currency)
if rate:
return float(rate)
# All else failed: fallback
return 1.0
Cache corruption: Clear and retry
def _load_cache(self) -> dict:
"""Load cache, clearing on corruption."""
try:
with open(self._cache_path, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
self._cache = {}
if self._cache_path.exists():
self._cache_path.unlink()
return {}
Exception hierarchy¶
class ForexError(Exception):
"""Base exception for forex operations."""
pass
class ForexAPIError(ForexError):
"""Raised when API request fails."""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class ForexCacheError(ForexError):
"""Raised when cache I/O fails."""
pass
class InvalidCurrencyError(ForexError):
"""Raised for invalid currency codes."""
pass
Configuration¶
Config schema¶
Add to hb-config.json under forex key (all fields optional):
Option reference¶
| Option | Type | Default | Purpose |
|---|---|---|---|
cache_ttl_hours |
int | 1 |
Cache validity in hours |
Defaults¶
All other settings are automatically derived or hardcoded:
- API provider: ExchangeRate-API (free, no authentication required)
- Cache path: Auto-derived as
{HomeBudgetData}/Forex/forex-rates.json - Fallback rate: Always
1.0(unit rate for no conversion) - Timeout:
5seconds for API requests - Offline mode: Disabled by default
If the forex key is omitted entirely from config, the feature uses all defaults with 1-hour TTL. Users only need to configure cache_ttl_hours if they want a different caching interval.
Appendix: Alternative API providers¶
exchangerate.host (APILayer)¶
- Free tier: 100 requests/month (very limited)
- Authentication: Requires API key signup
- Data freshness: Hourly updates (more frequent than ExchangeRate-API)
- Currencies: 168 supported
- Historical data: 19 years available
- Uptime: 99.9%
- Status: Not practical for this use case due to 100 req/mo limit with typical usage
Open Exchange Rates¶
- Free tier: 1,000 requests/month
- Authentication: Requires API key signup
- Currencies: 200+ (most comprehensive)
- Data model: USD-centric
- Status: Use as emergency fallback only if ExchangeRate-API becomes unavailable