up
This commit is contained in:
parent
83fc76b53d
commit
d0a43e357a
367 changed files with 131411 additions and 0 deletions
87
.agents/skills/evaluating-pegs/SKILL.md
Normal file
87
.agents/skills/evaluating-pegs/SKILL.md
Normal 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"es=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.
|
||||
109
.agents/skills/implementing-providers/SKILL.md
Normal file
109
.agents/skills/implementing-providers/SKILL.md
Normal 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`.
|
||||
112
.agents/skills/querying-wise-api/SKILL.md
Normal file
112
.agents/skills/querying-wise-api/SKILL.md
Normal 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"es=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
1
.claude/skills
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../.agents/skills
|
||||
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.*
|
||||
Dockerfile
|
||||
spec
|
||||
25
.github/dependabot.yml
vendored
Normal file
25
.github/dependabot.yml
vendored
Normal 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
82
.github/workflows/ci.yml
vendored
Normal 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' }}
|
||||
31
.github/workflows/dependabot-auto-merge.yml
vendored
Normal file
31
.github/workflows/dependabot-auto-merge.yml
vendored
Normal 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
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.bundle
|
||||
pkg
|
||||
tmp
|
||||
*.sqlite3*
|
||||
docs
|
||||
3
.irbrc
Normal file
3
.irbrc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "boot"
|
||||
13
.rubocop.yml
Normal file
13
.rubocop.yml
Normal 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
11
.rubocop_todo.yml
Normal 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
242
AGENTS.md
Normal 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
58
CHANGELOG.md
Normal 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
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal 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
40
Gemfile
Normal 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
206
Gemfile.lock
Normal 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
22
LICENSE
Normal 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
2
Procfile
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
web: bundle exec puma -C config/puma.rb
|
||||
scheduler: bundle exec ruby bin/schedule
|
||||
4
Rakefile
Normal file
4
Rakefile
Normal 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
13
bin/console
Executable 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
39
bin/schedule
Executable 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
4
boot.rb
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
$LOAD_PATH << File.expand_path("lib", __dir__)
|
||||
require "currency_patches"
|
||||
6
config.ru
Normal file
6
config.ru
Normal 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
10
config/openssl_legacy.cnf
Normal 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
13
config/puma.rb
Normal 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
|
||||
19
db/migrate/001_create_currencies.rb
Normal file
19
db/migrate/001_create_currencies.rb
Normal 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
|
||||
13
db/migrate/002_add_base_to_unique_index.rb
Normal file
13
db/migrate/002_add_base_to_unique_index.rb
Normal 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
|
||||
9
db/migrate/003_clear_boc_data.rb
Normal file
9
db/migrate/003_clear_boc_data.rb
Normal 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
|
||||
16
db/migrate/004_create_providers.rb
Normal file
16
db/migrate/004_create_providers.rb
Normal 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
|
||||
7
db/migrate/005_clear_tcmb_data.rb
Normal file
7
db/migrate/005_clear_tcmb_data.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Sequel.migration do
|
||||
up do
|
||||
from(:rates).where(provider: "TCMB").delete
|
||||
end
|
||||
end
|
||||
17
db/migrate/006_rename_provider_url.rb
Normal file
17
db/migrate/006_rename_provider_url.rb
Normal 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
|
||||
10
db/migrate/007_add_provider_currency_indexes.rb
Normal file
10
db/migrate/007_add_provider_currency_indexes.rb
Normal 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
|
||||
22
db/migrate/008_merge_nbp_providers.rb
Normal file
22
db/migrate/008_merge_nbp_providers.rb
Normal 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
|
||||
11
db/migrate/009_add_date_index.rb
Normal file
11
db/migrate/009_add_date_index.rb
Normal 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
|
||||
10
db/migrate/010_add_provider_publish_schedule.rb
Normal file
10
db/migrate/010_add_provider_publish_schedule.rb
Normal 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
|
||||
13
db/migrate/011_rename_boj_to_boja.rb
Normal file
13
db/migrate/011_rename_boj_to_boja.rb
Normal 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
|
||||
9
db/migrate/012_add_outlier_to_rates.rb
Normal file
9
db/migrate/012_add_outlier_to_rates.rb
Normal 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
|
||||
15
db/migrate/013_drop_outlier_from_rates.rb
Normal file
15
db/migrate/013_drop_outlier_from_rates.rb
Normal 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
|
||||
7
db/migrate/014_add_coverage_start_to_providers.rb
Normal file
7
db/migrate/014_add_coverage_start_to_providers.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Sequel.migration do
|
||||
change do
|
||||
add_column :providers, :coverage_start, :date
|
||||
end
|
||||
end
|
||||
13
db/migrate/015_rename_bot_to_bota.rb
Normal file
13
db/migrate/015_rename_bot_to_bota.rb
Normal 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
|
||||
7
db/migrate/016_add_pivot_currency_to_providers.rb
Normal file
7
db/migrate/016_add_pivot_currency_to_providers.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Sequel.migration do
|
||||
change do
|
||||
add_column :providers, :pivot_currency, String
|
||||
end
|
||||
end
|
||||
53
db/migrate/017_create_rollup_tables.rb
Normal file
53
db/migrate/017_create_rollup_tables.rb
Normal 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
|
||||
49
db/migrate/018_create_currency_tables.rb
Normal file
49
db/migrate/018_create_currency_tables.rb
Normal 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
|
||||
28
db/migrate/019_add_dates_to_currency_coverages.rb
Normal file
28
db/migrate/019_add_dates_to_currency_coverages.rb
Normal 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
|
||||
|
|
@ -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
|
||||
21
db/migrate/021_rename_bnm_to_nbm.rb
Normal file
21
db/migrate/021_rename_bnm_to_nbm.rb
Normal 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
|
||||
|
|
@ -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
|
||||
22
db/seeds/currency_patches.json
Normal file
22
db/seeds/currency_patches.json
Normal 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
8
db/seeds/pegs/aed.json
Normal 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
8
db/seeds/pegs/ang.json
Normal 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
8
db/seeds/pegs/bam.json
Normal 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
8
db/seeds/pegs/bhd.json
Normal 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
8
db/seeds/pegs/bmd.json
Normal 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
8
db/seeds/pegs/bnd.json
Normal 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
8
db/seeds/pegs/btn.json
Normal 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
8
db/seeds/pegs/cve.json
Normal 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
8
db/seeds/pegs/fkp.json
Normal 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
8
db/seeds/pegs/ggp.json
Normal 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
8
db/seeds/pegs/imp.json
Normal 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
8
db/seeds/pegs/jep.json
Normal 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
8
db/seeds/pegs/jod.json
Normal 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
8
db/seeds/pegs/mop.json
Normal 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
8
db/seeds/pegs/omr.json
Normal 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
8
db/seeds/pegs/qar.json
Normal 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
8
db/seeds/pegs/sar.json
Normal 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
8
db/seeds/pegs/shp.json
Normal 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
8
db/seeds/pegs/xaf.json
Normal 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
8
db/seeds/pegs/xof.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bam.json
Normal file
12
db/seeds/providers/bam.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/banrep.json
Normal file
12
db/seeds/providers/banrep.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/banxico.json
Normal file
12
db/seeds/providers/banxico.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bbk.json
Normal file
12
db/seeds/providers/bbk.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bcb.json
Normal file
12
db/seeds/providers/bcb.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bcch.json
Normal file
12
db/seeds/providers/bcch.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bccr.json
Normal file
12
db/seeds/providers/bccr.json
Normal 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"
|
||||
}
|
||||
11
db/seeds/providers/bceao.json
Normal file
11
db/seeds/providers/bceao.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bcn.json
Normal file
12
db/seeds/providers/bcn.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bcra.json
Normal file
12
db/seeds/providers/bcra.json
Normal 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"
|
||||
}
|
||||
11
db/seeds/providers/bcu.json
Normal file
11
db/seeds/providers/bcu.json
Normal 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"
|
||||
}
|
||||
11
db/seeds/providers/bdi.json
Normal file
11
db/seeds/providers/bdi.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bdp.json
Normal file
12
db/seeds/providers/bdp.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bi.json
Normal file
12
db/seeds/providers/bi.json
Normal 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"
|
||||
}
|
||||
11
db/seeds/providers/bnm.json
Normal file
11
db/seeds/providers/bnm.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bnr.json
Normal file
12
db/seeds/providers/bnr.json
Normal 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"
|
||||
}
|
||||
11
db/seeds/providers/bob.json
Normal file
11
db/seeds/providers/bob.json
Normal 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
|
||||
}
|
||||
12
db/seeds/providers/boc.json
Normal file
12
db/seeds/providers/boc.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/boe.json
Normal file
12
db/seeds/providers/boe.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/boi.json
Normal file
12
db/seeds/providers/boi.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/boj.json
Normal file
12
db/seeds/providers/boj.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/boja.json
Normal file
12
db/seeds/providers/boja.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bot.json
Normal file
12
db/seeds/providers/bot.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/bota.json
Normal file
12
db/seeds/providers/bota.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/cba.json
Normal file
12
db/seeds/providers/cba.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/cbc.json
Normal file
12
db/seeds/providers/cbc.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/cbk.json
Normal file
12
db/seeds/providers/cbk.json
Normal 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"
|
||||
}
|
||||
12
db/seeds/providers/cbm.json
Normal file
12
db/seeds/providers/cbm.json
Normal 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
|
||||
}
|
||||
12
db/seeds/providers/cbr.json
Normal file
12
db/seeds/providers/cbr.json
Normal 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"
|
||||
}
|
||||
11
db/seeds/providers/cbu.json
Normal file
11
db/seeds/providers/cbu.json
Normal 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
Loading…
Add table
Reference in a new issue