frankfurter/AGENTS.md

243 lines
12 KiB
Markdown
Raw Normal View History

2026-05-21 01:38:14 -06:00
# 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`.