243 lines
12 KiB
Markdown
243 lines
12 KiB
Markdown
|
|
# Frankfurter
|
||
|
|
|
||
|
|
Frankfurter is a free and open-source currency data API built with Ruby that tracks reference exchange rates from 50+ institutional sources (central banks, the IMF, the Federal Reserve, etc.).
|
||
|
|
|
||
|
|
## Architecture
|
||
|
|
|
||
|
|
- Roda
|
||
|
|
- SQLite with Sequel
|
||
|
|
- Puma
|
||
|
|
- Rufus scheduler
|
||
|
|
- Foreman
|
||
|
|
- Cloudflare CDN
|
||
|
|
|
||
|
|
## Project Structure
|
||
|
|
|
||
|
|
```
|
||
|
|
lib/
|
||
|
|
├── app.rb # Main Roda app — mounts v1 and v2
|
||
|
|
├── base_conversion.rb # Rebases rates from any base to a common base
|
||
|
|
├── blender.rb # Blends multi-provider rates: rebase → consensus → weighted average
|
||
|
|
├── bucket.rb # Shared SQL bucket expressions for weekly/monthly aggregation
|
||
|
|
├── cache.rb # Cloudflare cache purge
|
||
|
|
├── carry_forward.rb # Carries forward most recent provider rate within a lookback window
|
||
|
|
├── consensus.rb # Cross-provider outlier detection (MAD-based)
|
||
|
|
├── currency.rb # Currency model (materialized from rates)
|
||
|
|
├── currency_coverage.rb # CurrencyCoverage model (provider-currency join)
|
||
|
|
├── db.rb # Database configuration
|
||
|
|
├── currency_patches.rb # Patches Money::Currency: registers historical codes, fixes mangled names
|
||
|
|
├── log.rb # Shared logger
|
||
|
|
├── monthly_rate.rb # MonthlyRate model on monthly_rates rollup table
|
||
|
|
├── peg.rb # Currency peg definitions (from db/seeds/pegs/*.json)
|
||
|
|
├── provider.rb # Provider model: identity, backfill
|
||
|
|
├── provider/
|
||
|
|
│ ├── adapters/
|
||
|
|
│ │ ├── adapter.rb # Abstract adapter: fetch interface, chunked iteration
|
||
|
|
│ │ └── <key>.rb # One adapter per provider (auto-discovered)
|
||
|
|
│ └── adapters.rb # Auto-requires all adapters
|
||
|
|
├── rate.rb # Rate model on rates table
|
||
|
|
├── rate_scopes.rb # Shared dataset scopes for rate tables (rates, weekly, monthly)
|
||
|
|
├── roundable.rb # Currency-aware decimal rounding
|
||
|
|
├── weekly_rate.rb # WeeklyRate model on weekly_rates rollup table
|
||
|
|
├── weighted_average.rb # Recency-weighted averaging with exponential decay
|
||
|
|
├── versions/
|
||
|
|
│ ├── v1.rb # Legacy API (ECB-only, frozen)
|
||
|
|
│ ├── v1/ # V1 internals (quotes, query, rounding, currency names)
|
||
|
|
│ ├── v2.rb # Multi-provider API
|
||
|
|
│ └── v2/
|
||
|
|
│ └── rate_query.rb # V2 rate query builder (blending, filtering)
|
||
|
|
├── public/
|
||
|
|
│ ├── root.json # Root index document
|
||
|
|
│ ├── v1/openapi.json # V1 OpenAPI spec
|
||
|
|
│ └── v2/openapi.json # V2 OpenAPI spec
|
||
|
|
└── tasks/
|
||
|
|
├── consensus.rake # Consensus scan across providers
|
||
|
|
├── db.rake # Database migrations and setup
|
||
|
|
├── default.rake # Default task (lint + test)
|
||
|
|
├── providers.rake # Dynamic backfill task for all providers
|
||
|
|
├── rollups.rake # Rebuild weekly/monthly rollup tables
|
||
|
|
├── rubocop.rake # Linter task
|
||
|
|
└── test.rake # Test suite task
|
||
|
|
|
||
|
|
spec/ # Minitest test suite
|
||
|
|
db/migrate/ # Sequel migrations
|
||
|
|
db/seeds/
|
||
|
|
├── pegs/ # One JSON file per peg (e.g. aed.json, bam.json)
|
||
|
|
└── providers/ # One JSON file per provider (e.g. ecb.json, boi.json)
|
||
|
|
```
|
||
|
|
|
||
|
|
## Key Components
|
||
|
|
|
||
|
|
### Adapters (lib/provider/adapters/)
|
||
|
|
- `Provider::Adapters::Adapter`: Abstract base class — `fetch` interface, `fetch_each` for chunked iteration, sleep no-op in test env
|
||
|
|
- Adapters are pure data extraction: they know how to talk to an external API and parse its response
|
||
|
|
- No identity — adapters have no `key` or `name`. Provider model owns identity.
|
||
|
|
- Optional class methods: `def backfill_range = N`, `def api_key = ENV[...] || raise(ApiKeyMissing)`
|
||
|
|
- Auto-discovered from `lib/provider/adapters/` via loader
|
||
|
|
|
||
|
|
### Models
|
||
|
|
- `Rate`: Sequel model on `rates` table. Scopes via `RateScopes`: `latest(date)`, `between(interval)`, `only(*quotes)`, `downsample(precision)`
|
||
|
|
- `WeeklyRate`, `MonthlyRate`: Rollup models on `weekly_rates` / `monthly_rates`, share scopes via `RateScopes`
|
||
|
|
- `Currency`: Sequel model on `currencies` table. Materialized from rates during backfill. Tracks global date ranges per currency.
|
||
|
|
- `CurrencyCoverage`: Join model on `currency_coverages` table. One row per (provider, currency) with per-provider date ranges. Belongs to Provider and Currency.
|
||
|
|
- `Provider`: Sequel model on `providers` table. Static config-as-data: seeded from `db/seeds/providers/*.json` on every container start so provider metadata always tracks the image.
|
||
|
|
- `#adapter`: finds adapter by convention (`Provider::Adapters.const_get(key)`)
|
||
|
|
- `#backfill`: incremental backfill — starts from `last_synced` or `coverage_start`, delegates to `adapter.fetch_each`, filters excluded quotes, stamps provider key, upserts to DB, refreshes currency summaries
|
||
|
|
- `#start_date`, `#end_date`: derived from currency coverages
|
||
|
|
- `many_to_many :currencies` through `currency_coverages`
|
||
|
|
- `Peg`: Value object for currency pegs (from `db/seeds/pegs/*.json`)
|
||
|
|
|
||
|
|
### Blending Pipeline
|
||
|
|
- `Blender`: orchestrates rebase → consensus → weighted average
|
||
|
|
- `BaseConversion`: rebases rates from each provider's native base to a common base via inversion or cross rates
|
||
|
|
- `Consensus`: MAD-based outlier detection — flags rates that deviate significantly from the cross-provider median
|
||
|
|
- `WeightedAverage`: recency-weighted averaging with exponential decay past a grace period
|
||
|
|
|
||
|
|
### API (lib/app.rb)
|
||
|
|
- V1 at `/v1/*` — frozen legacy, ECB-only
|
||
|
|
- V2 at `/v2/*` — multi-provider with blended rates
|
||
|
|
- CORS enabled for all origins
|
||
|
|
- OpenAPI specs served as static files at `/v1/openapi.json` and `/v2/openapi.json`
|
||
|
|
|
||
|
|
### Scheduler (bin/schedule)
|
||
|
|
- Runs as its own process, started by foreman alongside the web server (see `Procfile`)
|
||
|
|
- Calls `provider.backfill` directly on Provider model instances
|
||
|
|
- Staggers startup backfill for all providers (2s apart)
|
||
|
|
- Cron schedule read from `publish_schedule` in the providers table (5-field cron; `null` for historical-only providers)
|
||
|
|
- Convention: poll every 30 min across a 3-hour window starting at the publish hour (encoded directly in the cron expression, e.g. `*/30 14-16 * * 1-5` for ECB)
|
||
|
|
- Backfill is incremental: fetches only from the last stored date forward
|
||
|
|
|
||
|
|
## Database
|
||
|
|
|
||
|
|
SQLite database with `rates`, `weekly_rates`, `monthly_rates`, `providers`, `currencies`, and `currency_coverages` tables.
|
||
|
|
|
||
|
|
### rates
|
||
|
|
- `date`, `base`, `quote`, `rate`, `provider`
|
||
|
|
- Unique index on `(provider, date, base, quote)`
|
||
|
|
|
||
|
|
### weekly_rates, monthly_rates
|
||
|
|
- Pre-aggregated rollups keyed by `bucket_date` (Monday for weekly, first-of-month for monthly)
|
||
|
|
- Rebuilt by `rake rollups:rebuild` and refreshed during backfill
|
||
|
|
|
||
|
|
### providers
|
||
|
|
- `key`, `name`, `rate_type`, `country_code`, `data_url`, `terms_url`, `publish_schedule`, `publish_cadence`, `coverage_start`, `pivot_currency`
|
||
|
|
- Seeded from `db/seeds/providers/*.json`
|
||
|
|
- `publish_schedule`: 5-field cron expression (minute hour day-of-month month day-of-week) in UTC, or `null` for historical-only providers. Convention: `*/30 H-H+2 * * D` where H is the publish hour and D is the day-of-week range, giving a 3-hour polling window.
|
||
|
|
- `publish_cadence`: one of `daily`, `weekly`, `monthly`, or `null` for historical-only providers. Dispatches `publishes_missed` to the right algorithm (per-fire-day count for daily; ISO-week bucket for weekly; year-month bucket for monthly).
|
||
|
|
- `coverage_start`: earliest date for historical data (used as backfill starting point)
|
||
|
|
|
||
|
|
### currencies
|
||
|
|
- `iso_code` (PK), `start_date`, `end_date`
|
||
|
|
- Global date range per currency, materialized during backfill
|
||
|
|
|
||
|
|
### currency_coverages
|
||
|
|
- `provider_key`, `iso_code`, `start_date`, `end_date`
|
||
|
|
- PK `(provider_key, iso_code)`
|
||
|
|
- Per-provider date range per currency, materialized during backfill
|
||
|
|
|
||
|
|
## Testing
|
||
|
|
|
||
|
|
```bash
|
||
|
|
APP_ENV=test bundle exec rake # Run linter and test suite
|
||
|
|
APP_ENV=test bundle exec rake rubocop # Run linter only
|
||
|
|
APP_ENV=test bundle exec rake spec # Run test suite only
|
||
|
|
```
|
||
|
|
|
||
|
|
Separate SQLite databases per environment (`APP_ENV`): test, development, production.
|
||
|
|
|
||
|
|
### Test stack
|
||
|
|
|
||
|
|
- Minitest
|
||
|
|
- Rack::Test for HTTP testing
|
||
|
|
- VCR + WebMock for HTTP recording/mocking
|
||
|
|
- Minitest-focus for targeted test runs
|
||
|
|
- Global transaction rollback via `Minitest::Spec#around`
|
||
|
|
- Test fixtures seed on suite load via `spec/helper.rb`
|
||
|
|
|
||
|
|
## Running Locally
|
||
|
|
|
||
|
|
```bash
|
||
|
|
bundle install # Install dependencies
|
||
|
|
bundle exec rake db:setup # Run migrations and seed providers
|
||
|
|
bundle exec rake backfill # Backfill all providers (takes a while)
|
||
|
|
bundle exec unicorn -c config/unicorn.rb # Start web server on port 8080
|
||
|
|
bundle exec foreman start # Start web + scheduler together (mirrors prod)
|
||
|
|
```
|
||
|
|
|
||
|
|
Or with Docker:
|
||
|
|
```bash
|
||
|
|
docker run -d -p 80:8080 lineofflight/frankfurter
|
||
|
|
```
|
||
|
|
|
||
|
|
### Legacy TLS
|
||
|
|
|
||
|
|
BCN's endpoint only supports TLS 1.0, which OpenSSL 3.5+ disables by default. Set `OPENSSL_CONF=config/openssl_legacy.cnf` to enable it. Without this, BCN skips backfill with "legacy TLS required, skipping".
|
||
|
|
|
||
|
|
## Rake Tasks
|
||
|
|
|
||
|
|
```bash
|
||
|
|
rake db:setup # Run migrations and seed providers
|
||
|
|
rake db:migrate # Run database migrations
|
||
|
|
rake db:seed # Seed provider metadata
|
||
|
|
rake backfill # Backfill all providers (threaded, incremental)
|
||
|
|
rake backfill[ecb] # Backfill a single provider
|
||
|
|
rake rollups:rebuild # Rebuild weekly and monthly rollups
|
||
|
|
rake rollups:rebuild[ecb] # Rebuild rollups for a single provider
|
||
|
|
```
|
||
|
|
|
||
|
|
## Adding a New Provider
|
||
|
|
|
||
|
|
See [.agents/skills/implementing-providers/SKILL.md](.agents/skills/implementing-providers/SKILL.md) for the full checklist and workflow.
|
||
|
|
|
||
|
|
## Currency Patches
|
||
|
|
|
||
|
|
`db/seeds/currency_patches.json` patches `Money::Currency` at boot via
|
||
|
|
`lib/currency_patches.rb`. Two purposes:
|
||
|
|
|
||
|
|
- Register historical ISO 4217 codes (pre-euro, pre-redenomination) the gem
|
||
|
|
doesn't include — full entry with `name`, `symbol`, `subunit_to_unit`, `iso_numeric`.
|
||
|
|
- Override mangled names on existing entries (e.g. `Cfa` → `CFA`) — partial
|
||
|
|
entry with just `iso_code` and `name`; existing fields are preserved via merge.
|
||
|
|
|
||
|
|
When adding a new provider, check whether it serves historical currencies and
|
||
|
|
note them in coverage research. To pick up previously-dropped records,
|
||
|
|
re-backfill the provider from its `coverage_start`:
|
||
|
|
|
||
|
|
```ruby
|
||
|
|
Provider["key"].backfill(after: Date.new(YYYY, 1, 1))
|
||
|
|
```
|
||
|
|
|
||
|
|
## Development Notes
|
||
|
|
|
||
|
|
- Ruby (see `Gemfile`)
|
||
|
|
- Linting: RuboCop with Shopify style guide (120-char line length)
|
||
|
|
- Migrations in `db/migrate/`
|
||
|
|
- Update `CHANGELOG.md` for changes that directly impact user experience
|
||
|
|
|
||
|
|
## Handling Data
|
||
|
|
|
||
|
|
Relay what providers publish. Don't editorialize.
|
||
|
|
|
||
|
|
## API Endpoints
|
||
|
|
|
||
|
|
### V2 (lib/versions/v2.rb)
|
||
|
|
|
||
|
|
Multi-provider API with blended rates. Full spec at `/v2/openapi.json`.
|
||
|
|
|
||
|
|
```
|
||
|
|
GET /v2/rates # latest blended rates
|
||
|
|
GET /v2/rates?base=USD # rebased
|
||
|
|
GET /v2/rates?quotes=USD,GBP # filtered
|
||
|
|
GET /v2/rates?date=2024-01-15 # specific date
|
||
|
|
GET /v2/rates?from=2024-01-01&to=2024-01-31 # date range
|
||
|
|
GET /v2/rates?providers=ecb,tcmb # filter by providers
|
||
|
|
GET /v2/currencies # currencies with names and providers
|
||
|
|
GET /v2/providers # available data providers
|
||
|
|
```
|
||
|
|
|
||
|
|
Response: normalized array of `{ date, base, quote, rate }` records.
|
||
|
|
|
||
|
|
### V1 (lib/versions/v1.rb)
|
||
|
|
|
||
|
|
Frozen legacy API, ECB-only. Full spec at `/v1/openapi.json`.
|