class EL_EXCHANGE_RATE_HISTORY_GRID
Client examples: CURRENCY_EXCHANGE_HISTORY_APP ; CURRENCY_EXCHANGE_HISTORY_COMMAND ; CURRENCY_TEST_SET
Exchange rate history grid using data from UK site https://www.exchangerates.org.uk. Grid consists of columns of currency rates for a given list of currencies relative to a base currency for one calender year. Each row is a day of the year.
CSV Export Example
Column No.,2,3,4 ,EUR (1),USD to EUR,GBP to EUR 20210101,1,0.8241,1.1268 20210102,1,0.824,1.1267 20210103,1,0.8163,1.117
See routine export_to_csv
Application to LibreOffice Calc
The resulting CSV export file can be imported into LibreOffice Calc or other spreadsheet application and used to convert an amount to a target base currency based on historical exchange rate.
Use an expression like the following to multiply by historical exchange rate based on date in column A, currency code in column D and amount to convert in column E
=VLOOKUP($A1, exchange_table, INDIRECT($D1))*$E1
This will work if you define a named range exchange_table. You also need to define the column number cells 2, 3, 4 etc, as single cell named ranges matching each of the currency codes.
note
description: "[
Exchange rate history grid using data from UK site [https://www.exchangerates.org.uk].
Grid consists of columns of currency rates for a given list of currencies relative to a
base currency for one calender year. Each row is a day of the year.
]"
notes: "[
**CSV Export Example**
Column No.,2,3,4
,EUR (1),USD to EUR,GBP to EUR
20210101,1,0.8241,1.1268
20210102,1,0.824,1.1267
20210103,1,0.8163,1.117
See routine `export_to_csv'
**Application to LibreOffice Calc**
The resulting CSV export file can be imported into LibreOffice Calc or other spreadsheet application
and used to convert an amount to a target base currency based on historical exchange rate.
Use an expression like the following to multiply by historical exchange rate based on date
in column A, currency code in column D and amount to convert in column E
=VLOOKUP($A1, exchange_table, INDIRECT($D1))*$E1
This will work if you define a named range `exchange_table'. You also need to define the column number
cells 2, 3, 4 etc, as single cell named ranges matching each of the currency codes.
]"
author: "Finnian Reilly"
copyright: "Copyright (c) 2001-2022 Finnian Reilly"
contact: "finnian at eiffel hyphen loop dot com"
license: "MIT license (See: en.wikipedia.org/wiki/MIT_License)"
date: "2025-10-20 17:22:02 GMT (Monday 20th October 2025)"
revision: "5"
class
EL_EXCHANGE_RATE_HISTORY_GRID
inherit
ARRAY2 [REAL]
rename
height as day_count,
make as make_grid,
put as put_rate,
item as rate_item alias "[]"
export
{NONE} all
{ANY} rate_item, day_count, width
end
EL_FALLIBLE
rename
put as put_error
export
{NONE} all
undefine
copy, is_equal
end
EL_IO_LOGGABLE
EL_MODULE_FILE; EL_MODULE_TUPLE
EL_SHARED_ENCODINGS
DATE_CONSTANTS
export
{NONE} all
undefine
copy, is_equal
end
create
make
feature {EL_COMMAND_CLIENT} -- Initialization
make (a_year: INTEGER; a_base_currency: STRING; a_currency_list: EL_STRING_8_LIST)
local
i_th_day, dec_31st: EL_DATE; r: REAL; year_day_count: INTEGER
do
make_lio
year := a_year; base_currency := a_base_currency; currency_list := a_currency_list
year_day_count := if is_leap_year (year) then Days_in_leap_year else Days_in_non_leap_year end
create parsed_date.make_now
create dec_31st.make (a_year, 12, 31)
-- Column 1 is reserved for `base_currency' which should be 1.0 for all days
make_filled (r.zero, year_day_count, a_currency_list.count + 1)
create day_of_year_table.make (day_count)
from create i_th_day.make (a_year, 1, 1) until i_th_day > dec_31st loop
day_of_year_table.extend (day_of_year_table.count + 1, i_th_day.ordered_compact_date)
put_rate (r.one, day_of_year_table.count, 1)
i_th_day.day_forth
end
currency_list.sort (False)
across currency_list as code until has_error loop
parse_html (code.cursor_index + 1, code.item)
end
ensure
valid_column_count: width = currency_list.count + 1
day_of_year_table_filled: day_of_year_table.count = day_count
end
feature -- Access
base_currency: STRING
day_rate (currency_code: STRING; date: DATE): REAL
require
valid_currency: has_currency (currency_code)
local
column, row: INTEGER
do
column := currency_list.index_of (currency_code, 1)
row := to_day_row (date)
if row > 0 and column > 0 then
Result := rate_item (row, column + 1)
end
end
year: INTEGER
feature -- Status query
has_currency (code: STRING): BOOLEAN
do
Result := currency_list.has (code)
end
feature -- Conversion
to_day_row (date: DATE): INTEGER
require
valid_year: date.year = year
do
Result := day_of_year_table [date.ordered_compact_date]
end
feature -- Basic operations
export_to_csv (output_path: FILE_PATH; date_format: STRING)
-- export to `output_path' as comma separated values with dates formatted as `date_format'
local
csv_file: PLAIN_TEXT_FILE; column, day_row: INTEGER; date: EL_DATE
do
create date.make_now
create csv_file.make_open_write (output_path)
csv_file.put_string ("Column No.,2")
across currency_list as code loop
csv_file.put_character (',')
csv_file.put_integer (code.cursor_index + 2)
end
csv_file.put_new_line
csv_file.put_string ("," + base_currency + " (1)")
across currency_list as code loop
csv_file.put_character (',')
csv_file.put_string (code.item + " to " + base_currency)
end
csv_file.put_new_line
across day_of_year_table as table loop
day_row := table.item
date.make_by_ordered_compact_date (table.key)
csv_file.put_string (date.formatted_out (date_format))
from column := 1 until column > width loop
csv_file.put_character (',')
csv_file.put_real (rate_item (day_row, column))
column := column + 1
end
csv_file.put_new_line
end
csv_file.close
end
feature {NONE} -- Implementation
parse_html (column_index: INTEGER; currency_code: STRING)
-- iterate over each HTML table row for `currency_code' starting: "<tr id="
local
row_intervals: EL_OCCURRENCE_INTERVALS; start_index, end_index: INTEGER
page_file: EL_CACHED_HTTP_FILE; history_url: ZSTRING
do
history_url := Url_template #$ [currency_code, base_currency, year]
lio.put_labeled_string ("Data source", history_url)
lio.put_new_line
create page_file.make (history_url, 10_000)
lio.put_string ("Parsing.")
previous_day_row := 0
if attached File.plain_text (page_file.path) as html then
create row_intervals.make_by_string (html, Table_row.open)
if attached row_intervals as row then
from row.start until row.after or has_error loop
start_index := row.item_lower
end_index := html.substring_index (Table_row.close, row.item_upper + 1)
if end_index > 0 then
parse_row (column_index, currency_code, html.substring (start_index, end_index + 4))
end
row.forth
end
end
end
if not has_error then
lio.put_line (" DONE")
lio.put_new_line
end
end
parse_row (column_index: INTEGER; currency_code, row_html: STRING)
-- Parse XML table row fragment as for example:
-- <tr id="01-01-2024" class="colone">
-- <td data-title="Date">Monday 1 January 2024</td>
-- <td data-title="Closing Rate">£1 GBP = €1.1534</td>
-- <td data-title="Get link">GBP/EUR rate for 01/01/2024</td>
-- </tr>
require
starts_with_tr: row_html.starts_with (Table_row.open)
ends_with_tr_close: row_html.ends_with (Table_row.close)
local
xdoc: EL_XML_DOC_CONTEXT; rate_string: ZSTRING; parsed_rate: REAL
error: EL_ERROR_DESCRIPTION; day_row: INTEGER
do
create xdoc.make_from_fragment (row_html, Encodings.Latin_1.code_page)
if attached xdoc.last_exception as exception then
put_error (exception.to_error)
error_list.last.extend (row_html)
else
parsed_date.make_with_format (xdoc [Xpath.id], Date_id_format)
rate_string := xdoc @ Xpath.td_2_text
parsed_rate := rate_string.substring_to_reversed (Euro_symbol).to_real
day_row := to_day_row (parsed_date)
if day_row > 0 then
if day_row \\ 5 = 0 then
lio.put_character ('.')
end
put_rate (parsed_rate, day_row, column_index)
previous_day_row := day_row
else
create error.make_substituted ("Missing day %S for %S", [previous_day_row + 1, currency_code])
put_error (error)
end
end
end
feature {NONE} -- Internal attributes
currency_list: EL_STRING_8_LIST
day_of_year_table: HASH_TABLE [INTEGER, INTEGER]
-- lookup day of year from `ordered_compact_date'
parsed_date: EL_DATE
previous_day_row: INTEGER
feature {NONE} -- Constants
Date_id_format: STRING = "dd-mm-yyyy"
-- id="31-01-2024"
Euro_symbol: CHARACTER_32 = '€'
Table_row: TUPLE [open, close: STRING]
once
create Result
Tuple.fill (Result, "<tr id=,</tr>")
end
Url_template: ZSTRING
once
Result := "https://www.exchangerates.org.uk/%S-%S-spot-exchange-rates-history-%S.html"
end
Xpath: TUPLE [id, td_2_text: STRING]
once
create Result
Tuple.fill (Result, "id, td [2]/text()")
end
end