113 lines
3.7 KiB
Ruby
113 lines
3.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "json"
|
|
require "net/http"
|
|
|
|
require "provider/adapters/adapter"
|
|
|
|
class Provider
|
|
module Adapters
|
|
# Banco de Portugal — pre-euro daily PTE reference rates from BPstat (JSON-stat 2.0).
|
|
# Coverage 1987-01-02 through 1998-12-31, when PTE was replaced by EUR.
|
|
#
|
|
# Direction: foreign currency in base, PTE in quote (1 foreign = X PTE), matching
|
|
# the convention used by other pivot-in-quote adapters (e.g. NBG, BBK).
|
|
#
|
|
# The dim_cats filter restricts to BdP-authored, PTE-referenced, daily series.
|
|
# Post-1999 EUR-quoted PTE series are sourced from LSEG (redistribution-restricted)
|
|
# and are structurally excluded by source=BdP, not by date. Post-1999 BdP-authored
|
|
# EUR rates would mirror ECB and are also excluded by reference=PTE.
|
|
class BDP < Adapter
|
|
DATASET_URL = "https://bpstat.bportugal.pt/data/v1/domains/29/datasets/" \
|
|
"23e0cdd56bddb4ad3016a9c3ad63a539/"
|
|
|
|
# reference=PTE (794), source=Banco de Portugal (35), periodicity=daily (4263).
|
|
# Re-included on every page request: the API's next_page URL drops some of these,
|
|
# which causes the result set to bleed in non-BdP-sourced rows.
|
|
DIM_CATS = ["13:794", "18:35", "40:4263"].freeze
|
|
|
|
# Series labels carry the ISO code in parens, e.g. "US, Dollars (USD) against Escudo - daily".
|
|
LABEL_CODE = /\(([A-Z]{3})\)/
|
|
|
|
# BdP labels the European Currency Unit as "ECU"; ISO 4217 uses XEU (numeric 954).
|
|
CODE_REMAP = { "ECU" => "XEU" }.freeze
|
|
|
|
class << self
|
|
def backfill_range = 1826 # ~5 years per chunk
|
|
end
|
|
|
|
def fetch(after: nil, upto: nil)
|
|
upto ||= Date.today
|
|
dataset = []
|
|
page = 1
|
|
|
|
loop do
|
|
json = fetch_page(after, upto, page)
|
|
dataset.concat(parse(json))
|
|
break unless json.dig("extension", "next_page")
|
|
|
|
page += 1
|
|
sleep(0.2)
|
|
end
|
|
|
|
dataset
|
|
end
|
|
|
|
def parse(json)
|
|
data = json.is_a?(String) ? JSON.parse(json) : json
|
|
return [] unless data.is_a?(Hash)
|
|
|
|
codes = build_codes(data)
|
|
counterparty_index = data.dig("dimension", "12", "category", "index") || []
|
|
date_index = data.dig("dimension", "reference_date", "category", "index") || []
|
|
values = data["value"] || []
|
|
num_dates = date_index.size
|
|
return [] if codes.empty? || num_dates.zero?
|
|
|
|
records = []
|
|
counterparty_index.each_with_index do |category_id, i|
|
|
code = codes[category_id]
|
|
next unless code
|
|
|
|
date_index.each_with_index do |date_str, j|
|
|
value = values[i * num_dates + j]
|
|
next if value.nil?
|
|
|
|
records << { date: Date.parse(date_str), base: code, quote: "PTE", rate: Float(value) }
|
|
end
|
|
end
|
|
|
|
records
|
|
end
|
|
|
|
private
|
|
|
|
def fetch_page(after, upto, page)
|
|
url = URI(DATASET_URL)
|
|
params = [["lang", "EN"], ["page", page]]
|
|
DIM_CATS.each { |dim| params << ["dim_cats", dim] }
|
|
params << ["obs_since", after.to_s] if after
|
|
params << ["obs_to", upto.to_s] if upto
|
|
url.query = URI.encode_www_form(params)
|
|
|
|
response = Net::HTTP.get_response(url)
|
|
response.value
|
|
JSON.parse(response.body)
|
|
end
|
|
|
|
def build_codes(data)
|
|
series = data.dig("extension", "series") || []
|
|
series.each_with_object({}) do |entry, codes|
|
|
counterparty = entry["dimension_category"]&.find { |c| c["dimension_id"] == 12 }
|
|
next unless counterparty
|
|
|
|
match = entry["label"]&.match(LABEL_CODE)
|
|
next unless match
|
|
|
|
code = match[1]
|
|
codes[counterparty["category_id"].to_s] = CODE_REMAP.fetch(code, code)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|