205 lines
6.5 KiB
Ruby
205 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
|