From e6a0ca597b6e09b4f132a0dfe8e8fd4cae9f5b48 Mon Sep 17 00:00:00 2001 From: Jestin J Palamuttam <34907800+jestinjoshi@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:59:53 -0400 Subject: [PATCH] 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 * 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=..."es=...&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 * 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 --------- Co-authored-by: Claude Sonnet 5 --- app/models/provider/frankfurter.rb | 151 +++++++++++ app/models/provider/registry.rb | 6 +- .../hostings/_provider_selection.html.erb | 3 +- config/locales/views/settings/hostings/en.yml | 1 + test/models/provider/frankfurter_test.rb | 245 ++++++++++++++++++ 5 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 app/models/provider/frankfurter.rb create mode 100644 test/models/provider/frankfurter_test.rb diff --git a/app/models/provider/frankfurter.rb b/app/models/provider/frankfurter.rb new file mode 100644 index 000000000..aca0e7511 --- /dev/null +++ b/app/models/provider/frankfurter.rb @@ -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=..."es=...&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 diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index ebe3d615b..aaeb5f98e 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -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 diff --git a/app/views/settings/hostings/_provider_selection.html.erb b/app/views/settings/hostings/_provider_selection.html.erb index 303a1e8ef..0c5c4476c 100644 --- a/app/views/settings/hostings/_provider_selection.html.erb +++ b/app/views/settings/hostings/_provider_selection.html.erb @@ -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") }, { diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index ed07e2437..de372a754 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -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. diff --git a/test/models/provider/frankfurter_test.rb b/test/models/provider/frankfurter_test.rb new file mode 100644 index 000000000..79a522ea3 --- /dev/null +++ b/test/models/provider/frankfurter_test.rb @@ -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