up
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / publish (push) Has been cancelled
CI/CD Pipeline / release (push) Has been cancelled

This commit is contained in:
BadMark 2026-05-21 01:38:14 -06:00
parent 83fc76b53d
commit d0a43e357a
367 changed files with 131411 additions and 0 deletions

View file

@ -0,0 +1,87 @@
---
description: Use when adding, evaluating, or removing an entry in db/seeds/pegs/, when the user mentions a pegged or fixed currency, or when an issue suggests we should "pin" or "lock" a currency to another. Also use when deciding whether to filter or override provider rates based on an assumed peg.
---
# Pegs Policy
The bar for entries in `db/seeds/pegs/`. The file is small, load-bearing, and easy to corrupt with well-meaning additions. This skill defines what qualifies and what doesn't.
## The Bar
A currency belongs in `pegs.json` only if **all** of the following are true:
1. **Officially asserted** by an issuing authority (central bank, monetary authority, currency board, or government).
2. **Currently in force** — not historical. If a peg ended, remove the entry; don't keep it with a sunset date.
3. **A specific rate** is asserted, not a band, target, or "managed float."
4. **A primary source** exists: the issuing authority's own page, a treaty, or legislation. Wikipedia is acceptable as a fallback for long-standing colonial / dependency pegs (FKP, SHP, GGP, IMP, JEP, BMD, BTN, MOP, CVE, ANG, QAR, OMR) where the authority doesn't publish a dedicated peg page, but a central-bank URL is preferred whenever available.
If any one of these fails, the currency does **not** belong in the file.
## What Does Not Qualify
- **De facto pegs**: stable in practice but never officially declared. Example: AZN/USD has been flat at 1.7 since May 2017, but CBAR has not asserted a peg. Adding it makes us responsible if it breaks.
- **Crawling pegs / managed floats**: the rate moves within a band (e.g., CNY) or is "managed" without a fixed target.
- **Currency boards with derived rates**: if the rate is mechanically derived from another (e.g., dollarization), the currency is its base — no peg row needed unless there's a non-1 multiplier.
- **Historical pegs**: if a peg has ended, it does not belong here. Look up the current regime.
- **Forecasts or expectations**: "expected to peg" or "will likely peg." Wait until it's official.
## Schema
Each entry in `db/seeds/pegs/`:
```json
{
"quote": "AED",
"base": "USD",
"rate": 3.6725,
"since": "1997-11-02",
"authority": "Central Bank of the UAE",
"source": "https://www.centralbank.ae/en/our-operations/currency-operations/exchange-rates"
}
```
All six fields are required (enforced by `Peg = Data.define(...)` in `lib/peg.rb`):
- `quote` — the pegged currency (ISO 4217)
- `base` — what it's pegged to (ISO 4217)
- `rate``1 base = rate quote`. Default 1.0 for parity pegs.
- `since` — date the current peg took effect, ISO 8601. Used to suppress peg-derived rates before this date.
- `authority` — the human-readable name of the asserting body.
- `source` — a stable URL. Central-bank page preferred; Wikipedia acceptable for long-standing dependency pegs.
## Adding an Entry
1. **Find the primary source.** Search the central bank or monetary authority's website. Look for "exchange rate policy," "monetary policy," or "currency arrangement." If only Wikipedia turns up something, check whether the bank's own site mentions it elsewhere — sometimes the peg is buried under a different title.
2. **Confirm the rate is current.** Some pages cite historical rates that have since been revised (e.g., Saudi riyal has had multiple regimes; the current peg dates to 1986). Match `since` to the current peg, not the original one.
3. **Add to `db/seeds/pegs/`** as `<quote>.json` (lowercase, e.g. `aed.json`).
4. **No code changes needed**`Peg.all` auto-discovers files in the directory.
5. **Add a test case** to `spec/peg_spec.rb` if the entry has unusual structure (non-1 rate, non-USD base, etc.) — for parity USD pegs, the existing tests cover it.
## Removing an Entry
If a peg is officially abandoned (e.g., 2015 CHF/EUR style), remove the entry. Do not leave it with an `until` field — the schema doesn't support one and the rate becomes wrong the moment the peg breaks.
If you discover an entry that doesn't meet the bar (a de facto peg slipped in), remove it.
## How Pegs Are Used
Pegs are treated as a source of rate data alongside providers. They contribute when the caller has not restricted the source set via `?providers=`. Two places in the codebase consume the peg files:
1. **`Currency.find` / `Currency.all`** (lib/currency.rb) — synthesizes a currency record for pegged currencies that have no provider coverage of their own (e.g., FKP). Without a peg, these would not appear in `/v2/currencies`.
2. **`PegAnchor`** (lib/peg_anchor.rb) — wraps `Blender` and applies all peg behavior in one place. It substitutes the peg rate for pegged quotes (matched-base or cross-base via the peg's base as a bridge), synthesizes rows for pegged currencies that providers do not cover, and rebases output to the user's base when the request base is itself pegged.
Cross-base requests like `?base=EUR&quotes=AED` are anchored through the peg's base: the result is `blended(EUR/USD) × peg(USD→AED)` rather than `blended(EUR/AED)`. This removes provider-disagreement noise on quantities the issuing authority has fixed.
When the caller scopes via `?providers=`, `RateQuery` bypasses `PegAnchor` and uses `Blender` directly. Pegs are excluded along with all other unlisted sources, so requests like `?base=BMD&providers=ecb` (where ECB does not publish BMD) return empty rather than synthesizing peg-derived rates.
## Why the Bar Matters
Frankfurter's editorial principle is **surface uncertainty, don't impose judgement**. Adding a de facto peg means we'd be asserting a peg that the issuing authority has not. If that peg breaks, we'd be the source of stale or wrong data — not because a provider got it wrong, but because we decided what reality looked like.
The bar exists so that anything in `db/seeds/pegs/` is defensible by appeal to a primary source. If a user asks "why does Frankfurter say AED is exactly 3.6725?", the answer is "because the Central Bank of the UAE says so" — not "because we observed it was usually that."
## Reference: Currently Listed Pegs
As of 2026-05, `db/seeds/pegs/` contains 20 entries. All are GCC dollar pegs, GBP-area dependencies, USD-area dependencies (Caribbean), or escudo/INR/HKD pegs with treaty backing. There are no de facto pegs in the file.
If you propose an addition, check that it fits one of these established categories or has a comparably strong source.

View file

@ -0,0 +1,109 @@
---
description: Use when adding a new exchange rate data provider, implementing a provider from a GitHub issue, when the user mentions a new central bank or data source, or when working on any issue labeled "provider". Also use when asked to backfill, fix, or update an existing provider.
---
# Adding a New Provider
Checklist for adding a new exchange rate data provider. Each step references an existing provider as a pattern to follow.
## Before You Start
- Identify the API endpoint and authentication requirements
- **Verify the API is accessible** — make a test request and confirm you get a 200 response with valid data. If the API returns 403, times out, or is otherwise inaccessible, **stop here**. Do not proceed with a hand-crafted cassette or fake data.
- **Read the API docs** — understand pagination, date filtering, and rate limiting. Some APIs require specific params for date ranges (e.g., HKMA needs `choose=end_of_day` for `from`/`to` to work). Getting this right avoids downloading the entire dataset on every request.
- Confirm the base currency and available quote currencies
- Check the publish schedule (timezone, frequency, days of week)
- Determine the earliest available date for historical data (goes in `coverage_start` in the seed file)
## Implementation Checklist
### 1. Adapter class — `lib/provider/adapters/<key>.rb`
Inherit from `Provider::Adapters::Adapter`. See any existing adapter for the pattern (e.g. `lib/provider/adapters/boi.rb`).
Required:
- `fetch(after: nil, upto: nil)` — fetches from the source API, returns an array of records
- Each record: `{ date:, base:, quote:, rate: }` (no `provider:` — the Provider model stamps that during import)
- **Rate direction**: match the provider's native convention. `pivot_currency` may appear as either `base` or `quote` depending on the source — don't invert.
- ECB publishes `1 EUR = X foreign`, pivot EUR goes in `base` (see `lib/provider/adapters/ecb.rb`).
- NBG and BBK publish `1 foreign = X pivot`, pivot goes in `quote` (see `lib/provider/adapters/nbg.rb` and `lib/provider/adapters/bbk.rb`).
- Store what the provider returns. Inverting in the adapter invites direction bugs and diverges from the blender's expectations.
Optional class methods (inside `class << self`):
- `backfill_range = N` — if the API needs chunked requests (e.g. max 100 results per call). The base class `fetch_each` uses this to iterate in windows.
- `def api_key = ENV["X_API_KEY"] || raise(Unavailable, "no API key")` — if the API requires authentication. This is not a blocker — implement the adapter regardless. It activates when the key is configured at deploy time.
Notes:
- Adapters have **no `key` or `name`** — Provider model owns identity. The adapter class name must match the provider key (e.g., `Provider::Adapters::ECB` for key `"ECB"`).
- The `base` and `quote` in each record are determined by the data, not a class method
- `parse` is a convention (not enforced by the base class) — most adapters define a `parse` method for unit-testable parsing, called from `fetch`
- Handle unit multipliers (per-100, per-1000) by dividing to normalize to per-1-unit rates. Guard against zero units before dividing.
- **Do not rescue errors** — let HTTP errors, timeouts, parse failures, and other exceptions bubble up. The scheduler handles retries; swallowing errors silently hides broken providers.
- **Per-day APIs**: Some APIs only return rates for a single date per request. A full backfill from e.g. 2000 means ~6,800 requests. Use `backfill_range` to chunk into small windows (e.g. 30 days) and add a `sleep` between requests to be polite. The base class `fetch_each` handles the iteration loop. See `lib/provider/adapters/nbg.rb` for a working example.
### 2. Tests — `spec/provider/adapters/<key>_spec.rb`
Follow the pattern in `spec/provider/adapters/boi_spec.rb` or `spec/provider/adapters/bccr_spec.rb`:
- VCR cassette setup in `before`/`after` blocks
- Integration test: `adapter.fetch(after:, upto:)`, assert dataset is non-empty and has expected structure
- Parse unit tests: call `parse` directly with inline fixture data
- Test edge cases: unit multipliers, empty values, invalid data
VCR cassettes (`spec/vcr_cassettes/<key>.yml`) are auto-created on the first live test run. Pin dates in tests — never use `Date.today` with VCR. **Never hand-craft or fabricate cassettes** — they must be recorded from a live API response. Use narrow date ranges in integration tests (3-5 days) to keep cassettes small and test runs fast.
**Avoiding time bombs**: Always pass explicit `upto:` dates in tests, even when the provider defaults to `Date.today`. If `upto` is omitted, the fetch will reach into unrecorded months and hit VCR errors on the 1st of the next month. Similarly, avoid assertions with hardcoded bounds on date counts (e.g. `<= 13` months) that break at month boundaries.
### 3. Seed provider metadata — `db/seeds/providers/<key>.json`
Create a single JSON file (not an array) with: `key`, `name`, `description`, `pivot_currency`, `data_url`, `terms_url` (nullable), `publish_schedule` (5-field cron expression in UTC, e.g. `"*/30 14-16 * * 1-5"` for daily Mon-Fri with a 3-hour polling window starting at 14:00 UTC; `null` for providers without a recurring cadence), `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, or null if unknown). Each provider has its own file — no shared file to conflict on.
The adapter class is auto-discovered from `lib/provider/adapters/` — no need to edit any wiring files.
### 4. Verify
```bash
APP_ENV=test bundle exec rake spec # All tests pass
APP_ENV=test bundle exec rake rubocop # No lint issues
bundle exec rake db:seed # Provider appears in seed data
bundle exec rake backfill[<key>] # Live backfill works
```
**Dry-run the backfill before shipping.** VCR tests only cover narrow date ranges. A real backfill exercises chunked iteration, API rate limits, and date range constraints that specs won't catch. Test at least one full `backfill_range` chunk against the live API to confirm the adapter works end-to-end — especially to verify the API's maximum allowed date range matches your `backfill_range` setting.
### 5. Sanity-check rates (before deploy)
After local backfill, compare the new provider's rates against an independent source **before pushing or deploying**. This catches direction bugs (base/quote swapped), unit errors (per-100 not normalized), or stale data before they reach production.
**Quick check — cross-reference with ECB rates in the local DB:**
```ruby
# In a console or one-liner: compare a sample of the new provider's rates against ECB
new_rates = Rate.where(provider: "<KEY>").where(date: Date.today - 7..Date.today).all
ecb_rates = Rate.where(provider: "ECB").where(date: Date.today - 7..Date.today).all
# Rebase both to EUR and compare overlapping quotes
```
**External check — use the `wise-api` skill** to compare against Wise mid-market rates. Sample a few major currency pairs (EUR/USD, EUR/GBP, EUR/JPY) and check deviation:
| Deviation | Assessment |
|-----------|-----------|
| < 0.5% | Good normal institutional vs real-time spread |
| 0.5-1% | Acceptable for less-liquid pairs |
| > 1% | Investigate — possible direction or unit error |
| > 5% | Almost certainly a bug (e.g. base/quote inverted) |
**What to look for:**
- Rates that are the reciprocal of expected (base/quote swapped) — this was the HNB bug — see 'Rate direction' principle above
- Rates that are 10x or 100x off (unit multiplier not normalized)
- Rates that match another provider exactly but on wrong dates (date parsing bug)
## Extending an existing adapter
When you widen an existing adapter to emit new record shapes (a new currency, a new pair, a new report block), `Provider#backfill` resumes from `last_synced` — so already-synced environments only fetch the new shape from the current date forward. To populate history, hand-backfill once at deploy:
```ruby
Provider["KEY"].backfill(after: Date.new(YYYY, M, D))
```
A fresh DB doesn't need this — it starts from `coverage_start`.

View file

@ -0,0 +1,112 @@
---
name: wise-api
description: Use when querying Wise for exchange rates (real-time or historical), validating Frankfurter rates against Wise mid-market, debugging rate discrepancies, or when the user mentions Wise, sanity check, or rate comparison.
---
# Wise Exchange Rate API
Query real-time and historical mid-market rates from Wise for troubleshooting and data validation.
## Endpoints
Base: `https://api.wise.com/v1/rates`
### Current rates
```bash
# Single pair
curl -s -H "Authorization: Bearer $WISE_API_KEY" \
'https://api.wise.com/v1/rates?source=EUR&target=USD' | jq
# All targets for a source (omit target entirely)
curl -s -H "Authorization: Bearer $WISE_API_KEY" \
'https://api.wise.com/v1/rates?source=EUR' | jq
```
**`target` only accepts a single currency.** Comma-separated lists (`target=USD,GBP,JPY`) return `400 Bad Request`. For multiple pairs from the same source, **omit `target` to get all ~163 currencies in one call** and filter the response locally. Loop per-pair only as a last resort.
### Historical rate at a specific time
```bash
curl -s -H "Authorization: Bearer $WISE_API_KEY" \
'https://api.wise.com/v1/rates?source=EUR&target=USD&time=2025-06-15T12:00:00' | jq
```
### Historical rate series
```bash
curl -s -H "Authorization: Bearer $WISE_API_KEY" \
'https://api.wise.com/v1/rates?source=EUR&target=USD&from=2025-06-01&to=2025-06-30&group=day' | jq
```
## Parameters
| Param | Description | Example |
|----------|------------------------------------------------|----------------------------------|
| `source` | Source currency (required) | `EUR` |
| `target` | Single target currency (omit for all) | `USD` |
| `time` | Single historical timestamp (ISO 8601) | `2025-06-15T12:00:00` |
| `from` | Period start (date or timestamp) | `2025-06-01` |
| `to` | Period end (date or timestamp, tz offset ok) | `2025-06-30T23:59:59+0100` |
| `group` | Grouping interval for series | `day`, `hour`, `minute` |
## Response
```json
[
{
"rate": 1.08234,
"source": "EUR",
"target": "USD",
"time": "2025-06-15T12:00:00+0000"
}
]
```
Always returns an array. Historical series returns one entry per group interval.
## Rate limit
500 requests/minute. Plenty for ad-hoc troubleshooting.
## Comparing with Frankfurter
```bash
# Frankfurter blended (multi-target OK here)
curl -s 'http://localhost:8080/v2/rates?base=EUR&quotes=USD,GBP,JPY' | jq
# Wise current: one call for all targets, filter locally
curl -s -H "Authorization: Bearer $WISE_API_KEY" \
'https://api.wise.com/v1/rates?source=EUR' \
| jq '.[] | select(.target | IN("USD","GBP","JPY"))'
```
Deviation: `abs(frankfurter - wise) / wise * 100`
| Deviation | Assessment |
|-----------|---------------------------------------------------------|
| < 0.1% | Excellent |
| 0.1-0.5% | Acceptable (institutional vs real-time spread) |
| > 0.5% | Investigate — stale data or provider outlier |
| > 1.0% | Likely data issue — check individual provider rates |
## Currency support
Not all currencies work as `source`. Exotic currencies (MMK, NPR, KGS, etc.) often return `400 Bad Request` when used as source. **Always use a major currency (EUR, USD, GBP) as `source` and put the exotic currency in `target`.**
```bash
# WRONG — will 400:
curl -s -H "Authorization: Bearer $WISE_API_KEY" \
'https://api.wise.com/v1/rates?source=MMK&target=USD'
# RIGHT:
curl -s -H "Authorization: Bearer $WISE_API_KEY" \
'https://api.wise.com/v1/rates?source=USD&target=MMK'
```
## Notes
- Wise rates are real-time; Frankfurter rates are daily institutional snapshots. Some spread is normal.
- For historical comparison, use `time` param with the date you're checking, not `from`/`to`.
- This is for internal validation only, Wise is NOT a Frankfurter provider.
- API docs: https://docs.wise.com/api-reference/rate

1
.claude/skills Symbolic link
View file

@ -0,0 +1 @@
../.agents/skills

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
.*
Dockerfile
spec

25
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,25 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "bundler" # See documentation for possible values
directory: "/" # Location of package manifests
assignees:
- "hakanensari"
schedule:
interval: "daily"
- package-ecosystem: "docker"
directory: "/"
assignees:
- "hakanensari"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
assignees:
- "hakanensari"
schedule:
interval: "weekly"

82
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,82 @@
name: CI/CD Pipeline
on:
push:
branches:
- main
tags:
- "v*"
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
env:
APP_ENV: test
OPENSSL_CONF: config/openssl_legacy.cnf
TCMB_API_KEY: test
FRED_API_KEY: test
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bundle exec rake
publish:
needs: test
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: elgohr/Publish-Docker-Github-Action@v5
with:
name: lineofflight/frankfurter
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
tag_names: true
release:
needs: test
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Extract version from tag
id: extract_version
run: |
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
echo "version_number=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Check if prerelease
id: prerelease
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
if [[ "$VERSION" == *-* ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Extract changelog entry
id: changelog
run: |
VERSION_NUMBER="${{ steps.extract_version.outputs.version_number }}"
awk "/^## \[$VERSION_NUMBER\]/{flag=1;next}/^## \[/{flag=0}flag" CHANGELOG.md > release_notes.md
if [ -s release_notes.md ]; then
echo "Found changelog entry for version $VERSION_NUMBER"
else
echo "No specific changelog entry found, using default"
echo "Release ${{ steps.extract_version.outputs.version }}" > release_notes.md
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.extract_version.outputs.version }}
name: ${{ steps.extract_version.outputs.version }}
body_path: release_notes.md
draft: false
prerelease: ${{ steps.prerelease.outputs.is_prerelease == 'true' }}

View file

@ -0,0 +1,31 @@
name: Auto-merge dependabot PRs
on: pull_request_target
permissions:
pull-requests: write
contents: write
jobs:
dependabot:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }}
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v3
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve PR
if: ${{ steps.metadata.outputs.update-type != 'version-update:semver-major' }}
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge for Dependabot PRs
if: ${{ steps.metadata.outputs.update-type != 'version-update:semver-major' }}
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.bundle
pkg
tmp
*.sqlite3*
docs

3
.irbrc Normal file
View file

@ -0,0 +1,3 @@
# frozen_string_literal: true
require_relative "boot"

13
.rubocop.yml Normal file
View file

@ -0,0 +1,13 @@
inherit_gem:
rubocop-shopify: rubocop.yml
inherit_from: ".rubocop_todo.yml"
plugins:
- rubocop-minitest
- rubocop-performance
- rubocop-rake
- rubocop-sequel
AllCops:
NewCops: enable

11
.rubocop_todo.yml Normal file
View file

@ -0,0 +1,11 @@
Metrics/AbcSize:
Max: 25.04
Metrics/BlockLength:
AllowedMethods: ['describe', 'route']
Metrics/MethodLength:
Max: 13
Minitest:
Include:
- '**/*_spec.rb'
Style/Documentation:
Enabled: false

242
AGENTS.md Normal file
View file

@ -0,0 +1,242 @@
# 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`.

58
CHANGELOG.md Normal file
View file

@ -0,0 +1,58 @@
# Changelog
All notable changes to the Frankfurter API will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.0.1] - 2026-05-19
### Fixed
- `/v2/rates` date-range queries no longer duplicate the first row of the response.
## [2.0.0] - 2026-05-18
New multi-provider API at `/v2/`. The v1 API is unchanged and remains available indefinitely at `/v1/`.
### Migrating from v1
- Change your base URL from `/v1/latest` to `/v2/rates`.
- Rates are now an array of `{"date", "base", "quote", "rate"}` objects instead of `{"base", "date", "rates": {"USD": 1.23}}`.
- The `symbols` parameter is renamed to `quotes`.
- `from` and `to` are used for date ranges.
- JSONP is not supported in v2.
### Added
- `/v2/rates` — blended exchange rates from 50+ institutional providers, derived from a USD-anchored blend so reciprocals and cross-rate triangles round to 1.0. Non-USD-base queries may differ from a single provider in the 4th to 5th decimal. (#343)
- `/v2/rate/{base}/{quote}` and `/v2/rate/{base}/{quote}/{date}` — single pair, latest or historical.
- `/v2/providers` — data sources with date ranges, currency coverage, `rate_type`, and `country_code`.
- `/v2/currencies` — provider coverage per currency, including peg metadata (anchor and fixed rate).
- Precious metal quotes (XAU, XAG, XPT, XPD).
- IMF Special Drawing Rights (XDR), including SDR cross rates as a primary source. (#333, #335)
- Historical currency support — pre-euro and pre-redenomination codes (DEM, FRF, NLG, CYP, etc.) where providers serve them.
- Pegged currency expansion at query time; pegged rates snap to the exact peg, and cross-base requests for pegged quotes are anchored through the peg's base. Pegs act as a source, so `?providers=` excludes them along with other unlisted sources. (#323)
- `expand=providers` on `/v2/rates` — each provider's individual rate as `[{ "key", "rate" }]`; excluded providers (outliers, peg overrides) are flagged `excluded: true`. CSV form `ECB:0.92|BOC:0.93`, `*`-suffixed when excluded. (#323)
- `providers` parameter to scope rates and currencies to specific sources.
- `group` parameter to downsample time series (`week` or `month`).
- CSV and NDJSON streaming output.
- Outlier detection and recency-weighted blending.
- Rows are stamped with their actual observation date; range queries do not carry forward. (#338)
- Strict parameter validation — unknown parameters return 422.
- Error responses are not cacheable, including streaming range queries.
_Pre-release history: see the [v2.0.0-beta.1](https://github.com/lineofflight/frankfurter/releases/tag/v2.0.0-beta.1) and [v2.0.0-beta.2](https://github.com/lineofflight/frankfurter/releases/tag/v2.0.0-beta.2) release notes._
## [1.0.0] - 2024-12-04
### Changed
- API versioning in URL path (v1)
- Migrated from PostgreSQL to SQLite
- Moved domain from <https://api.frankfurter.app> to <https://api.frankfurter.dev>. Former will continue serving the old
unversioned paths.
[2.0.1]: https://github.com/lineofflight/frankfurter/compare/v2.0.0...v2.0.1
[2.0.0]: https://github.com/lineofflight/frankfurter/compare/v1.0.0...v2.0.0
[1.0.0]: https://github.com/lineofflight/frankfurter/releases/tag/v1.0.0

1
CLAUDE.md Symbolic link
View file

@ -0,0 +1 @@
AGENTS.md

32
Dockerfile Normal file
View file

@ -0,0 +1,32 @@
FROM ruby:4.0.4-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
build-essential \
libyaml-dev && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m -u 1000 frankfurter
RUN mkdir /app && chown frankfurter:frankfurter /app
WORKDIR /app
COPY --chown=frankfurter:frankfurter Gemfile Gemfile.lock mise.toml ./
RUN gem install bundler && \
bundle config set --local deployment 'true' && \
bundle config set --local without 'development test' && \
bundle install
COPY --chown=frankfurter:frankfurter . .
ENV APP_ENV=production
ENV PORT=8080
USER frankfurter
HEALTHCHECK --interval=2s --timeout=4s --start-period=3s --retries=15 \
CMD curl -f "http://localhost:${PORT:-8080}" || exit 1
CMD ["sh", "-c", "bundle exec rake db:setup && exec bundle exec foreman start"]

40
Gemfile Normal file
View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
source "https://rubygems.org"
ruby file: "mise.toml"
gem "cgi"
gem "csv"
gem "foreman"
gem "irb"
gem "logger"
gem "money"
gem "oj"
gem "ox"
gem "puma"
gem "rack-cors"
gem "rake"
gem "roda"
gem "rufus-scheduler"
gem "sequel"
gem "sqlite3"
group :development, :test do
gem "rubocop-minitest"
gem "rubocop-performance"
gem "rubocop-rake"
gem "rubocop-sequel"
gem "rubocop-shopify"
end
group :test do
gem "minitest"
gem "minitest-around"
gem "minitest-focus"
gem "minitest-mock"
gem "rack-test"
gem "vcr"
gem "skooma"
gem "webmock"
end

206
Gemfile.lock Normal file
View file

@ -0,0 +1,206 @@
GEM
remote: https://rubygems.org/
specs:
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
bigdecimal (4.0.1)
cgi (0.5.1)
concurrent-ruby (1.3.6)
crack (1.0.1)
bigdecimal
rexml
csv (3.3.5)
date (3.5.1)
drb (2.2.3)
erb (6.0.4)
et-orbi (1.4.0)
tzinfo
foreman (0.90.0)
thor (~> 1.4)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
hana (1.3.7)
hashdiff (1.2.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
io-console (0.8.2)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.19.2)
json_skooma (0.2.5)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
uri-idna (~> 0.2)
zeitwerk (~> 2.6)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
minitest (6.0.2)
drb (~> 2.0)
prism (~> 1.5)
minitest-around (0.6.0)
minitest (> 5.0, < 7.0)
minitest-focus (1.4.1)
minitest (> 5.0)
minitest-mock (5.27.0)
money (7.0.2)
bigdecimal
i18n (~> 1.9)
nio4r (2.7.5)
oj (3.16.16)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
ostruct (0.6.3)
ox (2.14.23)
bigdecimal (>= 3.0)
parallel (1.27.0)
parser (3.3.10.2)
ast (~> 2.4.1)
racc
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
psych (5.3.1)
date
stringio
public_suffix (7.0.5)
puma (8.0.1)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.6)
rack-cors (3.0.0)
logger
rack (>= 3.0.14)
rack-test (2.2.0)
rack (>= 1.3)
rainbow (3.1.1)
rake (13.3.1)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
roda (3.102.0)
rack
rubocop (1.86.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-minitest (0.39.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rake (0.7.1)
lint_roller (~> 1.1)
rubocop (>= 1.72.1)
rubocop-sequel (0.4.1)
lint_roller (~> 1.1)
rubocop (>= 1.72.1, < 2)
rubocop-shopify (2.18.0)
rubocop (~> 1.62)
ruby-progressbar (1.13.0)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
sequel (5.102.0)
bigdecimal
skooma (0.3.7)
json_skooma (~> 0.2.5)
zeitwerk (~> 2.6)
sqlite3 (2.9.2-aarch64-linux-gnu)
sqlite3 (2.9.2-aarch64-linux-musl)
sqlite3 (2.9.2-arm-linux-gnu)
sqlite3 (2.9.2-arm-linux-musl)
sqlite3 (2.9.2-arm64-darwin)
sqlite3 (2.9.2-x86-linux-gnu)
sqlite3 (2.9.2-x86-linux-musl)
sqlite3 (2.9.2-x86_64-darwin)
sqlite3 (2.9.2-x86_64-linux-gnu)
sqlite3 (2.9.2-x86_64-linux-musl)
stringio (3.2.0)
thor (1.5.0)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri-idna (0.3.1)
vcr (6.4.0)
webmock (3.26.2)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
zeitwerk (2.7.5)
PLATFORMS
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86-linux-gnu
x86-linux-musl
x86_64-darwin
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
cgi
csv
foreman
irb
logger
minitest
minitest-around
minitest-focus
minitest-mock
money
oj
ox
puma
rack-cors
rack-test
rake
roda
rubocop-minitest
rubocop-performance
rubocop-rake
rubocop-sequel
rubocop-shopify
rufus-scheduler
sequel
skooma
sqlite3
vcr
webmock
RUBY VERSION
ruby 4.0.4
BUNDLED WITH
4.0.3

22
LICENSE Normal file
View file

@ -0,0 +1,22 @@
(The MIT License)
Copyright (c) Hakan Ensari
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

2
Procfile Normal file
View file

@ -0,0 +1,2 @@
web: bundle exec puma -C config/puma.rb
scheduler: bundle exec ruby bin/schedule

4
Rakefile Normal file
View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
require_relative "boot"
Dir.glob("lib/tasks/*.rake").each { |r| import r }

13
bin/console Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require_relative "../boot"
require "db"
require "rate"
require "currency"
require "provider"
puts "Frankfurter (#{ENV.fetch("APP_ENV", "development")})"
require "irb"
IRB.start

39
bin/schedule Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
$stdout.sync = true
require "rufus-scheduler"
require_relative "../boot"
require "provider"
require "provider/adapters"
dry_run = ARGV.include?("--dry-run")
scheduler = Rufus::Scheduler.new(max_work_threads: DB.pool.max_size) unless dry_run
# Backfill all providers on startup (staggered to avoid thundering herd)
Provider.all.shuffle.each do |provider|
if dry_run
puts "startup: backfill[#{provider.key.downcase}]"
else
scheduler.in("0s") do
provider.backfill
end
end
end
# Schedule each provider based on its publish_schedule cron expression.
Provider.all.each do |provider|
next unless provider.publish_schedule
if dry_run
puts "cron: #{provider.publish_schedule} backfill[#{provider.key.downcase}]"
else
scheduler.cron(provider.publish_schedule, overlap: false) do
provider.backfill
end
end
end
scheduler.join unless dry_run

4
boot.rb Normal file
View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
$LOAD_PATH << File.expand_path("lib", __dir__)
require "currency_patches"

6
config.ru Normal file
View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
require_relative "boot"
require "app"
run App.freeze.app

10
config/openssl_legacy.cnf Normal file
View file

@ -0,0 +1,10 @@
openssl_conf = default_conf
[default_conf]
ssl_conf = ssl_sect
[ssl_sect]
system_default = system_default_sect
[system_default_sect]
CipherString = DEFAULT:@SECLEVEL=0

13
config/puma.rb Normal file
View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
workers Integer(ENV.fetch("WORKER_PROCESSES", 4))
threads_count = Integer(ENV.fetch("MAX_THREADS", 5))
threads threads_count, threads_count
port Integer(ENV.fetch("PORT", 8080))
worker_timeout 10
preload_app!
before_fork do
Sequel::DATABASES.each(&:disconnect)
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
Sequel.migration do
up do
create_table :rates do
Date :date, null: false
String :base, null: false
String :quote, null: false
Float :rate, null: false
String :provider, null: false
index [:provider, :date, :quote], unique: true
end
end
down do
drop_table :rates
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
Sequel.migration do
up do
drop_index :rates, [:provider, :date, :quote], concurrently: true
add_index :rates, [:provider, :date, :base, :quote], unique: true, concurrently: true
end
down do
drop_index :rates, [:provider, :date, :base, :quote], concurrently: true
add_index :rates, [:provider, :date, :quote], unique: true, concurrently: true
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
Sequel.migration do
up do
["BOC", "CBA", "CBR", "NBP", "NBRB", "NBU", "BNM"].each do |provider|
from(:rates).where(provider:).delete
end
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
Sequel.migration do
up do
create_table :providers do
String :key, primary_key: true
String :name, null: false
String :description
String :url
end
end
down do
drop_table :providers
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
Sequel.migration do
up do
from(:rates).where(provider: "TCMB").delete
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
Sequel.migration do
up do
alter_table :providers do
rename_column :url, :data_url
add_column :terms_url, String
end
end
down do
alter_table :providers do
drop_column :terms_url
rename_column :data_url, :url
end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# rubocop:disable Sequel/ConcurrentIndex
Sequel.migration do
change do
add_index :rates, [:provider, :quote], name: :rates_provider_quote_index
add_index :rates, [:provider, :base], name: :rates_provider_base_index
end
end
# rubocop:enable Sequel/ConcurrentIndex

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
Sequel.migration do
up do
# Delete NBP.B rows that overlap with NBP (pre-2004 when both tables
# carried EUR, USD, etc.)
run <<~SQL
DELETE FROM rates
WHERE provider = 'NBP.B'
AND (date, base, quote) IN (
SELECT date, base, quote FROM rates WHERE provider = 'NBP'
)
SQL
self[:rates].where(provider: "NBP.B").update(provider: "NBP")
self[:providers].where(key: "NBP.B").delete
end
down do
raise Sequel::Error, "irreversible migration"
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
Sequel.migration do
up do
add_index :rates, :date, concurrently: true
end
down do
drop_index :rates, :date, concurrently: true
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
Sequel.migration do
change do
alter_table(:providers) do
add_column :publish_time, Integer
add_column :publish_days, String
end
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
Sequel.migration do
up do
from(:rates).where(provider: "BOJ").update(provider: "BOJA")
from(:providers).where(key: "BOJ").update(key: "BOJA")
end
down do
from(:rates).where(provider: "BOJA").update(provider: "BOJ")
from(:providers).where(key: "BOJA").update(key: "BOJ")
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
Sequel.migration do
change do
alter_table(:rates) do
add_column :outlier, :boolean, default: false, null: false
end
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
Sequel.migration do
up do
alter_table(:rates) do
drop_column :outlier
end
end
down do
alter_table(:rates) do
add_column :outlier, :boolean, default: false
end
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
Sequel.migration do
change do
add_column :providers, :coverage_start, :date
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
Sequel.migration do
up do
from(:rates).where(provider: "BOT").update(provider: "BOTA")
from(:providers).where(key: "BOT").update(key: "BOTA")
end
down do
from(:rates).where(provider: "BOTA").update(provider: "BOT")
from(:providers).where(key: "BOTA").update(key: "BOT")
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
Sequel.migration do
change do
add_column :providers, :pivot_currency, String
end
end

View file

@ -0,0 +1,53 @@
# frozen_string_literal: true
Sequel.migration do
up do
create_table(:weekly_rates) do
column :bucket_date, :date, null: false
String :provider, null: false
String :base, null: false
String :quote, null: false
Float :rate, null: false
primary_key [:provider, :bucket_date, :base, :quote]
index [:bucket_date, :quote]
end
create_table(:monthly_rates) do
column :bucket_date, :date, null: false
String :provider, null: false
String :base, null: false
String :quote, null: false
Float :rate, null: false
primary_key [:provider, :bucket_date, :base, :quote]
index [:bucket_date, :quote]
end
# Backfill weekly_rates
week_num = Sequel.cast(Sequel.function(:strftime, "%W", :date), Integer)
day_offset = Sequel.join(["+", week_num * 7, " days"])
year_start = Sequel.function(:strftime, "%Y-01-01", :date)
week_bucket = Sequel.function(:date, Sequel.function(:strftime, "%Y-%m-%d", year_start, day_offset))
self[:weekly_rates].insert(
[:bucket_date, :provider, :base, :quote, :rate],
self[:rates].select(week_bucket, :provider, :base, :quote, Sequel.function(:avg, :rate))
.group(:provider, :base, :quote, week_bucket),
)
# Backfill monthly_rates
month_bucket = Sequel.function(:strftime, "%Y-%m-01", :date)
self[:monthly_rates].insert(
[:bucket_date, :provider, :base, :quote, :rate],
self[:rates].select(month_bucket, :provider, :base, :quote, Sequel.function(:avg, :rate))
.group(:provider, :base, :quote, month_bucket),
)
end
down do
drop_table(:weekly_rates)
drop_table(:monthly_rates)
end
end

View file

@ -0,0 +1,49 @@
# frozen_string_literal: true
Sequel.migration do
up do
create_table(:currencies) do
String :iso_code, primary_key: true
column :start_date, :date, null: false
column :end_date, :date, null: false
end
create_table(:currency_coverages) do
String :provider_key, null: false
String :iso_code, null: false
primary_key [:provider_key, :iso_code]
end
# Backfill currencies from rates
run <<~SQL
INSERT INTO currencies (iso_code, start_date, end_date)
SELECT iso_code, MIN(start_date), MAX(end_date)
FROM (
SELECT quote AS iso_code, MIN(date) AS start_date, MAX(date) AS end_date
FROM rates GROUP BY quote
UNION ALL
SELECT base AS iso_code, MIN(date) AS start_date, MAX(date) AS end_date
FROM rates GROUP BY base
)
GROUP BY iso_code
ORDER BY iso_code
SQL
# Backfill currency_coverages from rates
run <<~SQL
INSERT INTO currency_coverages (provider_key, iso_code)
SELECT provider, iso_code FROM (
SELECT DISTINCT provider, quote AS iso_code FROM rates
UNION
SELECT DISTINCT provider, base AS iso_code FROM rates
)
ORDER BY provider, iso_code
SQL
end
down do
drop_table(:currency_coverages)
drop_table(:currencies)
end
end

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
Sequel.migration do
up do
add_column :currency_coverages, :start_date, :date
add_column :currency_coverages, :end_date, :date
# Backfill from rates
run <<~SQL
UPDATE currency_coverages
SET start_date = (
SELECT MIN(date) FROM rates
WHERE rates.provider = currency_coverages.provider_key
AND (rates.quote = currency_coverages.iso_code OR rates.base = currency_coverages.iso_code)
),
end_date = (
SELECT MAX(date) FROM rates
WHERE rates.provider = currency_coverages.provider_key
AND (rates.quote = currency_coverages.iso_code OR rates.base = currency_coverages.iso_code)
)
SQL
end
down do
drop_column :currency_coverages, :start_date
drop_column :currency_coverages, :end_date
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
Sequel.migration do
up do
alter_table :providers do
add_column :rate_type, String
add_column :country_code, String, size: 2
drop_column :description
end
end
down do
alter_table :providers do
add_column :description, String
drop_column :rate_type
drop_column :country_code
end
end
end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
Sequel.migration do
up do
from(:rates).where(provider: "BNM").update(provider: "NBM")
from(:currency_coverages).where(provider_key: "BNM").update(provider_key: "NBM")
# Clear stale NBM rollups from before the original rename, then re-key BNM.
from(:weekly_rates).where(provider: "NBM").delete
from(:weekly_rates).where(provider: "BNM").update(provider: "NBM")
from(:monthly_rates).where(provider: "NBM").delete
from(:monthly_rates).where(provider: "BNM").update(provider: "NBM")
end
down do
from(:currency_coverages).where(provider_key: "NBM").update(provider_key: "BNM")
from(:rates).where(provider: "NBM").update(provider: "BNM")
from(:weekly_rates).where(provider: "NBM").update(provider: "BNM")
from(:monthly_rates).where(provider: "NBM").update(provider: "BNM")
end
end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
Sequel.migration do
up do
alter_table :providers do
add_column :publish_schedule, String
add_column :publish_cadence, String
drop_column :publish_time
drop_column :publish_days
end
end
down do
alter_table :providers do
add_column :publish_time, Integer
add_column :publish_days, String
drop_column :publish_schedule
drop_column :publish_cadence
end
end
end

View file

@ -0,0 +1,22 @@
[
{ "iso_code": "DEM", "name": "Deutsche Mark", "symbol": "DM", "subunit_to_unit": 100, "iso_numeric": "276" },
{ "iso_code": "FRF", "name": "French Franc", "symbol": "F", "subunit_to_unit": 100, "iso_numeric": "250" },
{ "iso_code": "ITL", "name": "Italian Lira", "symbol": "₤", "subunit_to_unit": 1, "iso_numeric": "380" },
{ "iso_code": "ESP", "name": "Spanish Peseta", "symbol": "₧", "subunit_to_unit": 100, "iso_numeric": "724" },
{ "iso_code": "NLG", "name": "Dutch Guilder", "symbol": "ƒ", "subunit_to_unit": 100, "iso_numeric": "528" },
{ "iso_code": "ATS", "name": "Austrian Schilling", "symbol": "öS", "subunit_to_unit": 100, "iso_numeric": "040" },
{ "iso_code": "BEF", "name": "Belgian Franc", "symbol": "BF", "subunit_to_unit": 100, "iso_numeric": "056" },
{ "iso_code": "FIM", "name": "Finnish Markka", "symbol": "mk", "subunit_to_unit": 100, "iso_numeric": "246" },
{ "iso_code": "GRD", "name": "Greek Drachma", "symbol": "₯", "subunit_to_unit": 100, "iso_numeric": "300" },
{ "iso_code": "IEP", "name": "Irish Pound", "symbol": "IR£", "subunit_to_unit": 100, "iso_numeric": "372" },
{ "iso_code": "PTE", "name": "Portuguese Escudo", "symbol": "$", "subunit_to_unit": 100, "iso_numeric": "620" },
{ "iso_code": "LUF", "name": "Luxembourg Franc", "symbol": "F", "subunit_to_unit": 100, "iso_numeric": "442" },
{ "iso_code": "CYP", "name": "Cyprus Pound", "symbol": "£", "subunit_to_unit": 100, "iso_numeric": "196" },
{ "iso_code": "SIT", "name": "Slovenian Tolar", "symbol": "SIT", "subunit_to_unit": 100, "iso_numeric": "705" },
{ "iso_code": "TRL", "name": "Turkish Lira (old)", "symbol": "₤", "subunit_to_unit": 1, "iso_numeric": "792" },
{ "iso_code": "ROL", "name": "Romanian Leu (old)", "symbol": "L", "subunit_to_unit": 100, "iso_numeric": "642" },
{ "iso_code": "XEU", "name": "European Currency Unit", "symbol": "₠", "subunit_to_unit": 100, "iso_numeric": "954" },
{ "iso_code": "XAF", "name": "Central African CFA Franc" },
{ "iso_code": "XOF", "name": "West African CFA Franc" },
{ "iso_code": "XPF", "name": "CFP Franc" }
]

8
db/seeds/pegs/aed.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "AED",
"base": "USD",
"rate": 3.6725,
"since": "1997-11-02",
"authority": "Central Bank of the UAE",
"source": "https://www.centralbank.ae/en/our-operations/currency-operations/exchange-rates"
}

8
db/seeds/pegs/ang.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "ANG",
"base": "USD",
"rate": 1.79,
"since": "1971-12-18",
"authority": "Centrale Bank van Curaçao en Sint Maarten",
"source": "https://www.centralbank.cw/faq/monetary-foreign-exchange-policy"
}

8
db/seeds/pegs/bam.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "BAM",
"base": "EUR",
"rate": 1.95583,
"since": "1997-08-11",
"authority": "Central Bank of Bosnia and Herzegovina",
"source": "https://www.cbbh.ba/Content/Read/13"
}

8
db/seeds/pegs/bhd.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "BHD",
"base": "USD",
"rate": 0.376,
"since": "2001-01-01",
"authority": "Central Bank of Bahrain",
"source": "https://www.cbb.gov.bh/monetary-policy/"
}

8
db/seeds/pegs/bmd.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "BMD",
"base": "USD",
"rate": 1.0,
"since": "1972-02-06",
"authority": "Bermuda Monetary Authority",
"source": "https://en.wikipedia.org/wiki/Bermudian_dollar"
}

8
db/seeds/pegs/bnd.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "BND",
"base": "SGD",
"rate": 1.0,
"since": "1967-06-12",
"authority": "Brunei Darussalam Central Bank",
"source": "https://www.bdcb.gov.bn/monetary-policy/monetary-policy-framework"
}

8
db/seeds/pegs/btn.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "BTN",
"base": "INR",
"rate": 1.0,
"since": "1974-01-01",
"authority": "Royal Monetary Authority of Bhutan",
"source": "https://www.rma.org.bt/mpolicy/"
}

8
db/seeds/pegs/cve.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "CVE",
"base": "EUR",
"rate": 110.265,
"since": "1999-01-01",
"authority": "Banco de Cabo Verde",
"source": "https://www.bcv.cv/pt/Supervisao/Consumidores/Servi%C3%A7os%20ao%20P%C3%BAblico/perguntasrespostasfrequentes/mercadocambial/Paginas/MercadoCambial.aspx"
}

8
db/seeds/pegs/fkp.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "FKP",
"base": "GBP",
"rate": 1.0,
"since": "1966-02-14",
"authority": "Falkland Islands Government",
"source": "https://falklands.gov.fk/finance/currency"
}

8
db/seeds/pegs/ggp.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "GGP",
"base": "GBP",
"rate": 1.0,
"since": "1921-01-01",
"authority": "States of Guernsey",
"source": "https://en.wikipedia.org/wiki/Guernsey_pound"
}

8
db/seeds/pegs/imp.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "IMP",
"base": "GBP",
"rate": 1.0,
"since": "1840-01-01",
"authority": "Isle of Man Government",
"source": "https://en.wikipedia.org/wiki/Manx_pound"
}

8
db/seeds/pegs/jep.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "JEP",
"base": "GBP",
"rate": 1.0,
"since": "1834-01-01",
"authority": "States of Jersey",
"source": "https://en.wikipedia.org/wiki/Jersey_pound"
}

8
db/seeds/pegs/jod.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "JOD",
"base": "USD",
"rate": 0.709,
"since": "1995-10-23",
"authority": "Central Bank of Jordan",
"source": "https://www.cbj.gov.jo/Pages/viewpage.aspx?pageID=65"
}

8
db/seeds/pegs/mop.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "MOP",
"base": "HKD",
"rate": 1.03,
"since": "1983-01-01",
"authority": "Autoridade Monetária de Macau",
"source": "https://www.amcm.gov.mo/en/about-amcm/history/the-pataca"
}

8
db/seeds/pegs/omr.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "OMR",
"base": "USD",
"rate": 0.3845,
"since": "1986-01-01",
"authority": "Central Bank of Oman",
"source": "https://cbo.gov.om/Pages/FixedPeg.aspx"
}

8
db/seeds/pegs/qar.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "QAR",
"base": "USD",
"rate": 3.64,
"since": "2001-07-09",
"authority": "Qatar Central Bank",
"source": "https://www.qcb.gov.qa/Documents/BankInstructions/EN/01-02.pdf"
}

8
db/seeds/pegs/sar.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "SAR",
"base": "USD",
"rate": 3.75,
"since": "1986-06-01",
"authority": "Saudi Central Bank",
"source": "https://www.sama.gov.sa/en-US/MonetaryPolicy/Pages/ExchangeRatePolicy.aspx"
}

8
db/seeds/pegs/shp.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "SHP",
"base": "GBP",
"rate": 1.0,
"since": "1976-02-02",
"authority": "Government of Saint Helena",
"source": "https://en.wikipedia.org/wiki/Saint_Helena_pound"
}

8
db/seeds/pegs/xaf.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "XAF",
"base": "EUR",
"rate": 655.957,
"since": "1999-01-01",
"authority": "Banque des États de l'Afrique Centrale",
"source": "https://en.wikipedia.org/wiki/Central_African_CFA_franc"
}

8
db/seeds/pegs/xof.json Normal file
View file

@ -0,0 +1,8 @@
{
"quote": "XOF",
"base": "EUR",
"rate": 655.957,
"since": "1999-01-01",
"authority": "Banque Centrale des États de l'Afrique de l'Ouest",
"source": "https://en.wikipedia.org/wiki/West_African_CFA_franc"
}

View file

@ -0,0 +1,12 @@
{
"key": "BAM",
"name": "Bank Al-Maghrib",
"country_code": "MA",
"rate_type": "transfer rate",
"pivot_currency": "MAD",
"data_url": "https://apihelpdesk.centralbankofmorocco.ma/apis",
"terms_url": null,
"publish_schedule": "*/30 14-16 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "1999-01-04"
}

View file

@ -0,0 +1,12 @@
{
"key": "BANREP",
"name": "Banco de la República",
"country_code": "CO",
"rate_type": "representative market",
"pivot_currency": "COP",
"data_url": "https://www.datos.gov.co/Econom-a-y-Finanzas/Tasa-de-Cambio-Representativa-del-Mercado-TRM/32sa-8pi3/about_data",
"terms_url": null,
"publish_schedule": "*/30 23,0,1 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2000-01-03"
}

View file

@ -0,0 +1,12 @@
{
"key": "BANXICO",
"name": "Banco de México",
"country_code": "MX",
"rate_type": "FIX",
"pivot_currency": "MXN",
"data_url": "https://www.banxico.org.mx/SieAPIRest/service/v1/",
"terms_url": "https://www.banxico.org.mx/footer-en/terms-and-conditions.html",
"publish_schedule": "*/30 18-20 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "1991-11-12"
}

View file

@ -0,0 +1,12 @@
{
"key": "BBK",
"name": "Deutsche Bundesbank",
"country_code": "DE",
"rate_type": "historical Frankfurt fixing",
"pivot_currency": "DEM",
"data_url": "https://www.bundesbank.de/dynamic/action/en/statistics/time-series-databases/time-series-databases/745582/745582",
"terms_url": "https://www.bundesbank.de/de/startseite/benutzerhinweise/nutzungsbedingungen-fuer-den-allgemeinen-gebrauch-der-website-763554",
"publish_schedule": null,
"publish_cadence": null,
"coverage_start": "1948-06-21"
}

View file

@ -0,0 +1,12 @@
{
"key": "BCB",
"name": "Banco Central do Brasil",
"country_code": "BR",
"rate_type": "PTAX closing",
"pivot_currency": "BRL",
"data_url": "https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/",
"terms_url": null,
"publish_schedule": "*/30 16-18 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2000-01-01"
}

View file

@ -0,0 +1,12 @@
{
"key": "BCCH",
"name": "Banco Central de Chile",
"country_code": "CL",
"rate_type": "observed rate",
"pivot_currency": "CLP",
"data_url": "https://si3.bcentral.cl/Siete/es/Siete/API",
"terms_url": null,
"publish_schedule": "*/30 16-18 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "1990-01-02"
}

View file

@ -0,0 +1,12 @@
{
"key": "BCCR",
"name": "Banco Central de Costa Rica",
"country_code": "CR",
"rate_type": "reference rate",
"pivot_currency": "CRC",
"data_url": "https://sdd.bccr.fi.cr/es/IndicadoresEconomicos/Inicio/Contenedor/6",
"terms_url": "https://sdd.bccr.fi.cr/es/IndicadoresEconomicos/Inicio/TerminosDeUso",
"publish_schedule": "*/30 18-20 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2000-01-03"
}

View file

@ -0,0 +1,11 @@
{
"key": "BCEAO",
"name": "Banque Centrale des Etats de l'Afrique de l'Ouest",
"rate_type": "reference rate",
"pivot_currency": "XOF",
"data_url": "https://www.bceao.int/fr/cours/cours-de-reference-des-principales-devises-contre-Franc-CFA",
"terms_url": "https://www.bceao.int/fr/mentions-legales",
"publish_schedule": "*/30 10-12 * * 0-5",
"publish_cadence": "daily",
"coverage_start": "2014-01-02"
}

View file

@ -0,0 +1,12 @@
{
"key": "BCN",
"name": "Banco Central de Nicaragua",
"country_code": "NI",
"rate_type": "reference rate",
"pivot_currency": "NIO",
"data_url": "https://www.bcn.gob.ni/estadisticas/mercados_cambiarios/tipo_cambio/cordoba_dolar",
"terms_url": null,
"publish_schedule": "*/30 14-16 * * *",
"publish_cadence": "daily",
"coverage_start": "2012-01-01"
}

View file

@ -0,0 +1,12 @@
{
"key": "BCRA",
"name": "Banco Central de la República Argentina",
"country_code": "AR",
"rate_type": "reference rate",
"pivot_currency": "ARS",
"data_url": "https://www.bcra.gob.ar/en/monetary-and-financial-statistics/exchange-statistics/",
"terms_url": "https://www.bcra.gob.ar/aviso-legal/",
"publish_schedule": "*/30 16-18 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2000-01-03"
}

View file

@ -0,0 +1,11 @@
{
"key": "BCU",
"name": "Banco Central del Uruguay",
"country_code": "UY",
"pivot_currency": "UYU",
"data_url": "https://cotizaciones.bcu.gub.uy/wscotizaciones/servlet/awsbcucotizaciones",
"terms_url": "https://www.bcu.gub.uy/Paginas/Condiciones-de-uso.aspx",
"publish_schedule": "*/30 15-17 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2000-01-01"
}

View file

@ -0,0 +1,11 @@
{
"key": "BDI",
"name": "Banca d'Italia",
"country_code": "IT",
"pivot_currency": "EUR",
"data_url": "https://tassidicambio.bancaditalia.it/",
"terms_url": null,
"publish_schedule": "*/30 16-18 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "1999-01-04"
}

View file

@ -0,0 +1,12 @@
{
"key": "BDP",
"name": "Banco de Portugal",
"country_code": "PT",
"rate_type": "historical reference",
"pivot_currency": "PTE",
"data_url": "https://bpstat.bportugal.pt/",
"terms_url": null,
"publish_schedule": null,
"publish_cadence": null,
"coverage_start": "1987-01-02"
}

View file

@ -0,0 +1,12 @@
{
"key": "BI",
"name": "Bank Indonesia",
"country_code": "ID",
"rate_type": "transaction",
"pivot_currency": "IDR",
"data_url": "https://www.bi.go.id/en/statistik/informasi-kurs/transaksi-bi/default.aspx",
"terms_url": null,
"publish_schedule": "*/30 1-3 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2024-01-02"
}

View file

@ -0,0 +1,11 @@
{
"key": "BNM",
"name": "Bank Negara Malaysia",
"country_code": "MY",
"pivot_currency": "MYR",
"data_url": "https://www.bnm.gov.my/exchange-rates",
"terms_url": null,
"publish_schedule": "*/30 4-6 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2006-01-03"
}

View file

@ -0,0 +1,12 @@
{
"key": "BNR",
"name": "Banca Națională a României",
"country_code": "RO",
"rate_type": "reference rate",
"pivot_currency": "RON",
"data_url": "https://www.bnr.ro/Exchange-rates-702.aspx",
"terms_url": null,
"publish_schedule": "*/30 11-13 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2005-01-03"
}

View file

@ -0,0 +1,11 @@
{
"key": "BOB",
"name": "Bank of Botswana",
"country_code": "BW",
"pivot_currency": "BWP",
"data_url": "https://www.bankofbotswana.bw/exchange-rates",
"terms_url": "https://www.bankofbotswana.bw/content/disclaimer",
"publish_schedule": "*/30 10-12 * * 1-5",
"publish_cadence": "daily",
"coverage_start": null
}

View file

@ -0,0 +1,12 @@
{
"key": "BOC",
"name": "Bank of Canada",
"country_code": "CA",
"rate_type": "indicative rate",
"pivot_currency": "CAD",
"data_url": "https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/",
"terms_url": "https://www.bankofcanada.ca/terms/",
"publish_schedule": "*/30 20-22 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2017-01-03"
}

View file

@ -0,0 +1,12 @@
{
"key": "BOE",
"name": "Bank of England",
"country_code": "GB",
"rate_type": "spot rate",
"pivot_currency": "GBP",
"data_url": "https://www.bankofengland.co.uk/boeapps/database/Rates.asp?into=GBP",
"terms_url": "https://www.bankofengland.co.uk/legal",
"publish_schedule": "*/30 16-18 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2000-01-04"
}

View file

@ -0,0 +1,12 @@
{
"key": "BOI",
"name": "Bank of Israel",
"country_code": "IL",
"rate_type": "representative rate",
"pivot_currency": "ILS",
"data_url": "https://www.boi.org.il/en/economic-roles/statistics/foreign-exchange-market/exchange-rates/",
"terms_url": "https://www.boi.org.il/en/terms-of-use",
"publish_schedule": "*/30 12-14 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2000-01-01"
}

View file

@ -0,0 +1,12 @@
{
"key": "BOJ",
"name": "Bank of Japan",
"country_code": "JP",
"rate_type": "Tokyo market interbank",
"pivot_currency": "USD",
"data_url": "https://www.stat-search.boj.or.jp/index_en.html",
"terms_url": "https://www.stat-search.boj.or.jp/info/api_notice_en.pdf",
"publish_schedule": "*/30 0-2 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "1998-01-05"
}

View file

@ -0,0 +1,12 @@
{
"key": "BOJA",
"name": "Bank of Jamaica",
"country_code": "JM",
"rate_type": "counter rate",
"pivot_currency": "JMD",
"data_url": "https://boj.org.jm/market/foreign-exchange/counter-rates/",
"terms_url": "https://boj.org.jm/disclaimer/",
"publish_schedule": "*/30 18-20 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2006-01-03"
}

View file

@ -0,0 +1,12 @@
{
"key": "BOT",
"name": "Bank of Thailand",
"country_code": "TH",
"rate_type": "commercial bank",
"pivot_currency": "THB",
"data_url": "https://www.bot.or.th/en/statistics/exchange-rate.html",
"terms_url": null,
"publish_schedule": "*/30 11-13 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2002-01-02"
}

View file

@ -0,0 +1,12 @@
{
"key": "BOTA",
"name": "Bank of Tanzania",
"country_code": "TZ",
"rate_type": "indicative rate",
"pivot_currency": "TZS",
"data_url": "https://www.bot.go.tz/exchangerate/excrates",
"terms_url": null,
"publish_schedule": "*/30 8-10 * * *",
"publish_cadence": "daily",
"coverage_start": "1999-07-01"
}

View file

@ -0,0 +1,12 @@
{
"key": "CBA",
"name": "Central Bank of Armenia",
"country_code": "AM",
"rate_type": "average rate",
"pivot_currency": "AMD",
"data_url": "https://www.cba.am/en/SitePage/ExchangeArchive.aspx",
"terms_url": null,
"publish_schedule": "*/30 12-14 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "1999-01-04"
}

View file

@ -0,0 +1,12 @@
{
"key": "CBC",
"name": "Central Bank of the Republic of China (Taiwan)",
"country_code": "TW",
"rate_type": "interbank spot market closing",
"pivot_currency": "USD",
"data_url": "https://www.cbc.gov.tw/en/lp-4237-2.html",
"terms_url": null,
"publish_schedule": "*/30 8-10 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "1993-01-05"
}

View file

@ -0,0 +1,12 @@
{
"key": "CBK",
"name": "Central Bank of Kenya",
"country_code": "KE",
"rate_type": "indicative rate",
"pivot_currency": "KES",
"data_url": "https://www.centralbank.go.ke/forex/",
"terms_url": null,
"publish_schedule": "*/30 8-10 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "2003-09-12"
}

View file

@ -0,0 +1,12 @@
{
"key": "CBM",
"name": "Central Bank of Myanmar",
"country_code": "MM",
"rate_type": "reference rate",
"pivot_currency": "MMK",
"data_url": "https://forex.cbm.gov.mm/api/latest",
"terms_url": null,
"publish_schedule": "*/30 4-6 * * 1-5",
"publish_cadence": "daily",
"coverage_start": null
}

View file

@ -0,0 +1,12 @@
{
"key": "CBR",
"name": "Central Bank of Russia",
"country_code": "RU",
"rate_type": "official rate",
"pivot_currency": "RUB",
"data_url": "https://www.cbr.ru/eng/currency_base/daily/",
"terms_url": "https://www.cbr.ru/eng/user_agreement/",
"publish_schedule": "*/30 9-11 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "1999-01-04"
}

View file

@ -0,0 +1,11 @@
{
"key": "CBU",
"name": "Central Bank of Uzbekistan",
"country_code": "UZ",
"pivot_currency": "UZS",
"data_url": "https://cbu.uz/en/arkhiv-kursov-valyut/json/",
"terms_url": null,
"publish_schedule": "*/30 8-10 * * 1-5",
"publish_cadence": "daily",
"coverage_start": "1994-07-01"
}

Some files were not shown because too many files have changed in this diff Show more