feat(provider): add Frankfurter as an exchange-rate provider (#2640)

* feat(exchange-rates): add Frankfurter as an exchange-rate provider

Frankfurter (frankfurter.dev) is a free, keyless FX rates API backed by
ECB daily reference rates, with no published rate limit and no auth
flow to maintain (unlike Yahoo Finance's reverse-engineered cookie/
crumb auth or TwelveData's fast-exhausting free tier).

Follows the Provider::MoexPublic template: Faraday client with retry
middleware, SslConfigurable for self-hosted CA support, a light
RateLimitable throttle, and a FRANKFURTER_URL env escape hatch for
self-hosters. Registered as exchange-rates-only (no security/stock
data) and added to the hosting settings dropdown.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(provider): switch Frankfurter to the v2 API

v1 is explicitly marked "frozen" on Frankfurter's own root endpoint; v2
is "current" and covers 201 currencies across 84 central banks vs v1's
~30 ECB-only. Confirmed via the v2 OpenAPI spec and live requests:

- Single-date lookups now use GET /rate/{base}/{quote}?date=..., which
  carries weekends/holidays forward server-side (a Saturday returns a
  real rate directly), so the provider no longer needs its own
  lookback-window logic.
- Range lookups now use GET /rates?base=...&quotes=...&from=...&to=...,
  a flat array of { date, base, quote, rate } records (v2's shape)
  instead of v1's { "rates": { date => currencies } } hash.
- Every calendar day in a range is present (v2 gapfills itself), rather
  than v1's omit-non-trading-days behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(provider): sanitize currency codes before URL path interpolation

from/to were only upcased before being interpolated directly into the
URL path in fetch_exchange_rate (GET /rate/{from}/{to}). Low risk since
currency codes come from validated internal sources, but adds cheap
defense-in-depth: strip anything that isn't A-Z, matching the ISO 4217
format real currency codes always take.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jestin J Palamuttam
2026-07-13 20:59:53 -04:00
committed by GitHub
parent 6db11708ea
commit e6a0ca597b
5 changed files with 404 additions and 2 deletions

View File

@@ -0,0 +1,151 @@
# Frankfurter (https://frankfurter.dev), a free, keyless, open-source FX rates
# API backed by exchange rates blended across multiple central banks (ECB,
# FED, BOC, etc). No auth, no key, no published rate limit, and self-hostable
# (out of scope here, we just consume the public instance, with
# FRANKFURTER_URL as an escape hatch for self-hosters later).
#
# This targets Frankfurter's v2 API (https://api.frankfurter.dev/v2), not v1.
# Per Frankfurter's own root endpoint, v1 is status "frozen" (stable, no new
# features) while v2 is status "current" (the actively developed version)
# and covers far more currencies (201 across 84 central banks, vs v1's ~30
# ECB-only). v2 also carries forward weekends/holidays server-side (a single
# date lookup on a non-trading day returns the last known rate directly), so
# unlike v1 this provider does not need its own lookback-window logic.
class Provider::Frankfurter < Provider
include ExchangeRateConcept, RateLimitable
extend SslConfigurable
Error = Class.new(Provider::Error)
RateLimitError = Class.new(Error)
# No published rate limit, but a light throttle is cheap insurance.
MIN_REQUEST_INTERVAL = 0.15
def initialize
# No API key required, public endpoint only.
end
def healthy?
with_provider_response do
body = get_json("/currencies")
raise Error, "Frankfurter currencies endpoint returned no data" if body.blank?
true
end
end
def usage
with_provider_response do
UsageData.new(used: nil, limit: nil, utilization: nil, plan: "Free (no key required)")
end
end
# GET /rate/{base}/{quote}?date=... -> { date:, base:, quote:, rate: }.
# Frankfurter carries forward weekends/holidays itself, so the returned
# date may differ from the requested one but is never simply missing.
def fetch_exchange_rate(from:, to:, date:)
from = sanitize_currency(from)
to = sanitize_currency(to)
with_provider_response do
if from == to
Rate.new(date: date, from: from, to: to, rate: 1.0)
else
body = get_json("/rate/#{from}/#{to}", "date" => date.to_s)
raise Error, "Unexpected Frankfurter response shape" unless body.is_a?(Hash) && body["rate"]
begin
parsed_date = Date.parse(body["date"].to_s)
rescue Date::Error => e
raise Error, "Invalid date in Frankfurter response: #{e.message}"
end
Rate.new(date: parsed_date, from: from, to: to, rate: body["rate"].to_f)
end
end
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
from = sanitize_currency(from)
to = sanitize_currency(to)
with_provider_response do
if from == to
generate_same_currency_rates(from, to, start_date, end_date)
else
exchange_rates(from, to, start_date, end_date)
end
end
end
def max_history_days
nil # Backed by central bank reference rates going back decades, no bounded window.
end
private
# from/to are interpolated directly into the URL path in
# fetch_exchange_rate (GET /rate/{from}/{to}), so strip anything that
# isn't a letter before use - real ISO 4217 codes are always A-Z anyway.
def sanitize_currency(code)
code.to_s.upcase.gsub(/[^A-Z]/, "")
end
def base_url
ENV["FRANKFURTER_URL"].presence || "https://api.frankfurter.dev/v2"
end
def get_json(path, params = {})
throttle_request
response = client.get("#{base_url}#{path}") do |req|
params.each { |k, v| req.params[k] = v }
end
JSON.parse(response.body)
rescue JSON::ParserError => e
raise Error, "Invalid Frankfurter response: #{e.message}"
end
def client
@client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
faraday.options.open_timeout = 5
faraday.options.timeout = 20
faraday.request(:retry, {
max: 3,
interval: 0.5,
interval_randomness: 0.5,
backoff_factor: 2,
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [ Faraday::ConnectionFailed ]
})
faraday.request :json
faraday.response :raise_error
faraday.headers["Accept"] = "application/json"
end
end
def generate_same_currency_rates(from, to, start_date, end_date)
(start_date..end_date).map do |date|
Rate.new(date: date, from: from, to: to, rate: 1.0)
end
end
# GET /rates?base=...&quotes=...&from=...&to=... -> a flat array of
# { date:, base:, quote:, rate: } records, one per day in range (v2
# carries forward weekends/holidays itself, so every calendar day in the
# range is present, not just trading days).
def exchange_rates(from, to, start_date, end_date)
body = get_json("/rates", "base" => from, "quotes" => to, "from" => start_date.to_s, "to" => end_date.to_s)
raise Error, "Unexpected Frankfurter response shape (expected an array)" unless body.is_a?(Array)
body.filter_map do |entry|
next nil unless entry.is_a?(Hash) && entry["quote"] == to
rate_value = entry["rate"]
next nil if rate_value.nil?
Rate.new(date: Date.parse(entry["date"].to_s), from: from, to: to, rate: rate_value.to_f)
end.sort_by(&:date)
rescue Date::Error => e
raise Error, "Invalid date in Frankfurter response: #{e.message}"
end
end

View File

@@ -145,6 +145,10 @@ class Provider::Registry
Provider::MoexPublic.new
end
def frankfurter
Provider::Frankfurter.new
end
def tinkoff_invest
api_key = ENV["TINKOFF_INVEST_API_KEY"].presence || Setting.tinkoff_invest_api_key # pipelock:ignore
@@ -182,7 +186,7 @@ class Provider::Registry
def available_providers
case concept
when :exchange_rates
%i[twelve_data yahoo_finance moex_public]
%i[twelve_data yahoo_finance moex_public frankfurter]
when :securities
%i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi binance_public moex_public tinkoff_invest]
when :llm

View File

@@ -15,7 +15,8 @@
[
[t(".providers.twelve_data"), "twelve_data"],
[t(".providers.yahoo_finance"), "yahoo_finance"],
[t(".providers.moex_public"), "moex_public"]
[t(".providers.moex_public"), "moex_public"],
[t(".providers.frankfurter"), "frankfurter"]
],
{ label: t(".exchange_rate_provider_label") },
{

View File

@@ -56,6 +56,7 @@ en:
binance_public: Binance
moex_public: MOEX
tinkoff_invest: T-Invest (T-Bank)
frankfurter: Frankfurter
assistant_settings:
title: AI Assistant
description: Choose how the chat assistant responds. Builtin uses your configured LLM provider directly. External delegates to a remote AI agent that can call back to Sure's financial tools via MCP.

View File

@@ -0,0 +1,245 @@
require "test_helper"
class Provider::FrankfurterTest < ActiveSupport::TestCase
setup do
@provider = Provider::Frankfurter.new
@provider.stubs(:throttle_request)
end
# ================================
# Same-currency shortcut
# ================================
test "fetch_exchange_rate returns 1.0 for the same currency without calling the API" do
@provider.expects(:get_json).never
response = @provider.fetch_exchange_rate(from: "USD", to: "USD", date: Date.current)
assert response.success?
assert_equal 1.0, response.data.rate
assert_equal "USD", response.data.from
assert_equal "USD", response.data.to
end
test "fetch_exchange_rates returns 1.0 rates for every date in range for the same currency" do
@provider.expects(:get_json).never
start_date = Date.current - 3
end_date = Date.current
response = @provider.fetch_exchange_rates(from: "EUR", to: "EUR", start_date: start_date, end_date: end_date)
assert response.success?
assert_equal 4, response.data.size
assert response.data.all? { |r| r.rate == 1.0 }
end
test "fetch_exchange_rate treats mixed-case same currency as the same-currency shortcut" do
@provider.expects(:get_json).never
response = @provider.fetch_exchange_rate(from: "usd", to: "USD", date: Date.current)
assert response.success?
assert_equal 1.0, response.data.rate
end
test "fetch_exchange_rate strips non-letter characters from currency codes before building the URL path" do
date = Date.current - 5
body = { "date" => date.to_s, "base" => "USD", "quote" => "INR", "rate" => 83.1 }
@provider.expects(:get_json).with("/rate/USD/INR", has_entries("date" => date.to_s)).returns(body)
response = @provider.fetch_exchange_rate(from: "US/../D", to: "IN;R", date: date)
assert response.success?
assert_equal "USD", response.data.from
assert_equal "INR", response.data.to
end
# ================================
# fetch_exchange_rate (GET /rate/{base}/{quote})
# ================================
test "fetch_exchange_rate returns the direct cross-rate for a real pair" do
date = Date.current - 5
stub_rate(from: "INR", to: "CAD", date: date, body: { "date" => date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.01484 })
response = @provider.fetch_exchange_rate(from: "INR", to: "CAD", date: date)
assert response.success?
assert_equal date, response.data.date
assert_in_delta 0.01484, response.data.rate
assert_equal "INR", response.data.from
assert_equal "CAD", response.data.to
end
test "fetch_exchange_rate matches a lowercase target currency against Frankfurter's uppercase response" do
date = Date.current - 5
stub_rate(from: "USD", to: "INR", date: date, body: { "date" => date.to_s, "base" => "USD", "quote" => "INR", "rate" => 83.1 })
response = @provider.fetch_exchange_rate(from: "usd", to: "inr", date: date)
assert response.success?
assert_in_delta 83.1, response.data.rate
assert_equal "USD", response.data.from
assert_equal "INR", response.data.to
end
test "fetch_exchange_rate uses whatever date Frankfurter's own carry-forward returns" do
# v2 carries weekends/holidays forward server-side, so the response date
# can legitimately differ from the requested one - we trust it as-is.
requested_date = Date.current - 5
carried_forward_date = requested_date - 2
stub_rate(from: "USD", to: "INR", date: requested_date, body: { "date" => carried_forward_date.to_s, "base" => "USD", "quote" => "INR", "rate" => 91.0 })
response = @provider.fetch_exchange_rate(from: "USD", to: "INR", date: requested_date)
assert response.success?
assert_equal carried_forward_date, response.data.date
assert_in_delta 91.0, response.data.rate
end
test "fetch_exchange_rate fails without raising when the API call errors" do
@provider.stubs(:get_json).raises(StandardError.new("boom"))
response = @provider.fetch_exchange_rate(from: "USD", to: "INR", date: Date.current)
assert_not response.success?
assert_instance_of Provider::Frankfurter::Error, response.error
end
test "fetch_exchange_rate fails without raising when the response is missing a rate" do
date = Date.current - 5
stub_rate(from: "USD", to: "INR", date: date, body: { "date" => date.to_s, "base" => "USD", "quote" => "INR" })
response = @provider.fetch_exchange_rate(from: "USD", to: "INR", date: date)
assert_not response.success?
assert_instance_of Provider::Frankfurter::Error, response.error
end
# ================================
# fetch_exchange_rates (GET /rates)
# ================================
test "fetch_exchange_rates returns a sorted range of rates" do
start_date = Date.current - 5
end_date = Date.current - 1
body = [
{ "date" => start_date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0148 },
{ "date" => (start_date + 1).to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0149 },
{ "date" => end_date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0150 }
]
stub_range(from: "INR", to: "CAD", start_date: start_date, end_date: end_date, body: body)
response = @provider.fetch_exchange_rates(from: "INR", to: "CAD", start_date: start_date, end_date: end_date)
assert response.success?
assert_equal 3, response.data.size
assert_equal response.data.map(&:date), response.data.map(&:date).sort
assert_equal start_date, response.data.first.date
assert_equal end_date, response.data.last.date
end
test "fetch_exchange_rates includes every calendar day (v2 carries weekends/holidays forward itself)" do
start_date = Date.new(2024, 3, 16) # Saturday
end_date = Date.new(2024, 3, 17) # Sunday
body = [
{ "date" => "2024-03-16", "base" => "INR", "quote" => "CAD", "rate" => 0.01631 },
{ "date" => "2024-03-17", "base" => "INR", "quote" => "CAD", "rate" => 0.01631 }
]
stub_range(from: "INR", to: "CAD", start_date: start_date, end_date: end_date, body: body)
response = @provider.fetch_exchange_rates(from: "INR", to: "CAD", start_date: start_date, end_date: end_date)
assert response.success?
assert_equal 2, response.data.size
end
test "fetch_exchange_rates ignores entries for a different quote currency" do
start_date = Date.current - 3
end_date = Date.current - 1
body = [
{ "date" => start_date.to_s, "base" => "INR", "quote" => "CAD", "rate" => 0.0148 },
{ "date" => start_date.to_s, "base" => "INR", "quote" => "USD", "rate" => 0.012 }
]
stub_range(from: "INR", to: "CAD", start_date: start_date, end_date: end_date, body: body)
response = @provider.fetch_exchange_rates(from: "INR", to: "CAD", start_date: start_date, end_date: end_date)
assert response.success?
assert_equal 1, response.data.size
assert_equal "CAD", response.data.first.to
end
# ================================
# Error handling
# ================================
test "fetch_exchange_rates fails without raising when the response is not an array" do
@provider.stubs(:get_json).returns({ "status" => 422, "message" => "invalid currency" })
response = @provider.fetch_exchange_rates(
from: "INR", to: "CAD", start_date: Date.current - 5, end_date: Date.current - 1
)
assert_not response.success?
assert_instance_of Provider::Frankfurter::Error, response.error
end
test "fetch_exchange_rates fails without raising on a network error" do
@provider.stubs(:get_json).raises(Faraday::ConnectionFailed.new("connection refused"))
response = @provider.fetch_exchange_rates(
from: "INR", to: "CAD", start_date: Date.current - 5, end_date: Date.current - 1
)
assert_not response.success?
assert_instance_of Provider::Frankfurter::Error, response.error
end
# ================================
# healthy? / usage / max_history_days
# ================================
test "healthy? returns true when the currencies endpoint responds" do
@provider.stubs(:get_json).with("/currencies").returns([ { "iso_code" => "USD", "name" => "United States Dollar" } ])
response = @provider.healthy?
assert response.success?
assert response.data
end
test "healthy? fails when the currencies endpoint returns nothing" do
@provider.stubs(:get_json).with("/currencies").returns([])
response = @provider.healthy?
assert_not response.success?
end
test "usage reports a free, keyless plan" do
response = @provider.usage
assert response.success?
assert_equal "Free (no key required)", response.data.plan
assert_nil response.data.limit
end
test "max_history_days is nil (unbounded)" do
assert_nil @provider.max_history_days
end
private
def stub_rate(from:, to:, date:, body:)
@provider.stubs(:get_json)
.with("/rate/#{from}/#{to}", has_entries("date" => date.to_s))
.returns(body)
end
def stub_range(from:, to:, start_date:, end_date:, body:)
@provider.stubs(:get_json)
.with("/rates", has_entries("base" => from, "quotes" => to, "from" => start_date.to_s, "to" => end_date.to_s))
.returns(body)
end
end