frankfurter/lib/provider/adapters/mas.rb
BadMark d0a43e357a
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / publish (push) Has been cancelled
CI/CD Pipeline / release (push) Has been cancelled
up
2026-05-21 01:38:14 -06:00

204 lines
6.5 KiB
Ruby

# frozen_string_literal: true
require "csv"
require "net/http"
require "provider/adapters/adapter"
class Provider
module Adapters
# Monetary Authority of Singapore. Publishes daily exchange rates for 20
# currencies against the Singapore dollar (SGD) via a statistics page that
# serves CSV downloads. Rates are quoted as SGD per unit (or per 100 units)
# of foreign currency. Data available from 1988.
class MAS < Adapter
URL = "https://eservices.mas.gov.sg/statistics/msb/ExchangeRates.aspx"
# Column header patterns mapped to ISO currency codes and units.
# Per-unit currencies have unit=1, per-100-unit currencies have unit=100.
COLUMNS = {
"Euro" => { code: "EUR", unit: 1 },
"Pound Sterling" => { code: "GBP", unit: 1 },
"US Dollar" => { code: "USD", unit: 1 },
"Australian Dollar" => { code: "AUD", unit: 100 },
"Canadian Dollar" => { code: "CAD", unit: 100 },
"Chinese Renminbi" => { code: "CNY", unit: 100 },
"Hong Kong Dollar" => { code: "HKD", unit: 100 },
"Indian Rupee" => { code: "INR", unit: 100 },
"Indonesian Rupiah" => { code: "IDR", unit: 100 },
"Japanese Yen" => { code: "JPY", unit: 100 },
"Korean Won" => { code: "KRW", unit: 100 },
"Malaysian Ringgit" => { code: "MYR", unit: 100 },
"New Taiwan Dollar" => { code: "TWD", unit: 100 },
"New Zealand Dollar" => { code: "NZD", unit: 100 },
"Philippine Peso" => { code: "PHP", unit: 100 },
"Qatar Riyal" => { code: "QAR", unit: 100 },
"Saudi Arabia Riyal" => { code: "SAR", unit: 100 },
"Swiss Franc" => { code: "CHF", unit: 100 },
"Thai Baht" => { code: "THB", unit: 100 },
"UAE Dirham" => { code: "AED", unit: 100 },
"Vietnamese Dong" => { code: "VND", unit: 100 },
}.freeze
class << self
def backfill_range = 365
end
def fetch(after: nil, upto: nil)
end_date = upto || Date.today
dataset = []
each_year(after, end_date) do |start_month, start_year, end_month, end_year|
csv = download_csv(start_month, start_year, end_month, end_year)
dataset.concat(parse(csv))
sleep(1)
end
dataset.select! { |r| r[:date] >= after } if after
dataset.select! { |r| r[:date] <= end_date }
dataset
end
def parse(csv)
lines = csv.lines
header_index = lines.index { |l| l.start_with?("End of Period") }
return [] unless header_index
header_line = lines[header_index]
columns = parse_header(header_line)
return [] if columns.empty?
data_lines = lines[(header_index + 1)..]
records = []
current_year = nil
current_month = nil
data_lines.each do |line|
row = CSV.parse_line(line)
next unless row && row.length > 3
current_year = row[0].strip.to_i if row[0] && !row[0].strip.empty?
current_month = parse_month(row[1].strip) if row[1] && !row[1].strip.empty?
day = row[2]&.strip&.to_i
next unless current_year&.positive? && current_month&.positive? && day&.positive?
date = Date.new(current_year, current_month, day)
columns.each do |col_index, meta|
value = row[col_index]&.strip
next if value.nil? || value.empty?
rate = Float(value, exception: false)
next unless rate&.positive?
records << { date:, base: meta[:code], quote: "SGD", rate: rate / meta[:unit] }
end
end
records
end
private
def download_csv(start_month, start_year, end_month, end_year)
uri = URI(URL)
# Step 1: GET page to extract ASP.NET tokens and cookies
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
get_response = http.get(uri.request_uri)
cookies = extract_cookies(get_response)
tokens = extract_tokens(get_response.body)
# Step 2: POST to download CSV
form_data = tokens.merge(
"ctl00$ContentPlaceHolder1$StartYearDropDownList" => start_year.to_s,
"ctl00$ContentPlaceHolder1$EndYearDropDownList" => end_year.to_s,
"ctl00$ContentPlaceHolder1$StartMonthDropDownList" => start_month.to_s,
"ctl00$ContentPlaceHolder1$EndMonthDropDownList" => end_month.to_s,
"ctl00$ContentPlaceHolder1$FrequencyDropDownList" => "D",
"ctl00$ContentPlaceHolder1$DownloadButton" => "Download",
)
# Check all currency checkboxes
3.times { |i| form_data["ctl00$ContentPlaceHolder1$EndOfPeriodPerUnitCheckBoxList$#{i}"] = "on" }
18.times { |i| form_data["ctl00$ContentPlaceHolder1$EndOfPeriodPer100UnitsCheckBoxList$#{i}"] = "on" }
post = Net::HTTP::Post.new(uri.request_uri)
post["Cookie"] = cookies
post.set_form_data(form_data)
response = http.request(post)
response.body
end
def extract_cookies(response)
cookies = []
response.get_fields("set-cookie")&.each do |cookie|
cookies << cookie.split(";").first
end
cookies.join("; ")
end
def extract_tokens(html)
tokens = {}
["__VIEWSTATE", "__VIEWSTATEGENERATOR", "__EVENTVALIDATION"].each do |name|
match = html.match(/name="#{name}"[^>]*value="([^"]*)"/)
tokens[name] = match[1] if match
end
tokens
end
def parse_header(header_line)
columns = {}
parts = CSV.parse_line(header_line)
parts.each_with_index do |col, index|
next unless col
COLUMNS.each do |name, meta|
if col.include?(name)
columns[index] = meta
break
end
end
end
columns
end
MONTHS = {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
}.freeze
def parse_month(str)
MONTHS[str]
end
def each_year(start_date, end_date)
date = start_date
while date <= end_date
year_end = Date.new(date.year, 12, 31)
chunk_end = [year_end, end_date].min
start_month = date.month
end_month = chunk_end.month
yield start_month, date.year, end_month, chunk_end.year
date = year_end + 1
end
end
end
end
end