# 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