File: cadex.py
   1 #!/usr/bin/python3
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 2020-2025 pacman64
   6 #
   7 # Permission is hereby granted, free of charge, to any person obtaining a copy
   8 # of this software and associated documentation files (the “Software”), to deal
   9 # in the Software without restriction, including without limitation the rights
  10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11 # copies of the Software, and to permit persons to whom the Software is
  12 # furnished to do so, subject to the following conditions:
  13 #
  14 # The above copyright notice and this permission notice shall be included in
  15 # all copies or substantial portions of the Software.
  16 #
  17 # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23 # SOFTWARE.
  24 
  25 
  26 from datetime import datetime, timedelta
  27 from json import loads
  28 from sys import argv, exit, stderr
  29 from typing import Dict, List
  30 from urllib.request import urlopen
  31 
  32 
  33 info = '''
  34 cadex [amount...] [currency code...]
  35 
  36 The CAnaDian EXchange is a currency converter for the canadian dollar (CAD)
  37 from/to all the major/top-traded international currencies.
  38 
  39 All data come straight from the Bank of Canada, and are updated at 4pm,
  40 Monday to Friday.
  41 '''
  42 
  43 # a leading help-option arg means show the help message and quit
  44 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'):
  45     print(info.strip())
  46     exit(0)
  47 
  48 
  49 name_aliases: Dict[str, str] = {
  50     'AU': 'AUD',
  51     'BR': 'BRL',
  52     'CH': 'CHF',
  53     'CN': 'CNY',
  54     'EU': 'EUR',
  55     'EURO': 'EUR',
  56     'GB': 'GBP',
  57     'HK': 'HKD',
  58     'ID': 'IDR',
  59     'IN': 'INR',
  60     'JP': 'JPY',
  61     'KR': 'KRW',
  62     'ME': 'MXN',
  63     'MX': 'MXN',
  64     'NO': 'NOK',
  65     'NZ': 'NZD',
  66     'PE': 'PEN',
  67     'RU': 'RUB',
  68     'SA': 'SAR',
  69     'SE': 'SEK',
  70     'SG': 'SGD',
  71     'TR': 'TRY',
  72     'TW': 'TWD',
  73     'UK': 'GBP',
  74     'US': 'USD',
  75     'USA': 'USD',
  76     'ZA': 'ZAR',
  77 }
  78 
  79 # which currencies to convert, and the order in which to present them in the output table
  80 cur_list: List[str] = [
  81     'USD', 'MXN', 'EUR', 'GBP', 'AUD', 'CNY', 'INR', 'CHF',  'BRL', 'HKD', 'IDR',
  82     'JPY', 'NZD', 'NOK', 'PEN', 'RUB', 'SAR', 'SGD', 'ZAR', 'KRW', 'SEK', 'TWD',
  83     'TRY'
  84 ]
  85 
  86 cur_names: Dict[str, str] = {
  87     'USD': 'US Dollar',
  88     'MXN': 'Mexican Peso',
  89     'EUR': 'Euro',
  90     'GBP': 'British Pound',
  91     'AUD': 'Australian Dollar',
  92     'CNY': 'Chinese Yuan',
  93     'INR': 'Indian Rupee',
  94     'CHF': 'Swiss Franc',
  95     'BRL': 'Brazilian Real',
  96     'HKD': 'Hong Kong Dollar',
  97     'IDR': 'Indonesian Rupiah',
  98     'JPY': 'Japanese Yen',
  99     'NZD': 'New Zealand Dollar',
 100     'NOK': 'Norwegian Korona',
 101     'PEN': 'Peruvian New Sol',
 102     'RUB': 'Russian Ruble',
 103     'SAR': 'Saudi Riyal',
 104     'SGD': 'Singaporean Dollar',
 105     'ZAR': 'South African Rand',
 106     'KRW': 'Korean Wong',
 107     'SEK': 'Swedish Korona',
 108     'TWD': 'Taiwanese Dollar',
 109     'TRY': 'Turkish Lira',
 110 }
 111 
 112 amount: float = 1
 113 rates: Dict[str, float] = {}
 114 currency: str = ''
 115 
 116 for v in argv[1:]:
 117     try:
 118         amount = float(eval(v))
 119     except Exception:
 120         currency = v.upper()
 121         if currency in name_aliases:
 122             currency = name_aliases[currency]
 123 
 124 # going back 3 days guarantees this script will work even on weekends, while
 125 # minimizing data-transfers from the website
 126 now = datetime.today()
 127 since = now - timedelta(days=3)
 128 
 129 #
 130 # fetch and parse the exchange rates
 131 #
 132 
 133 # start with a reassuring message saying something is happening
 134 ymd = f'{now.year}-{now.month:02}-{now.day:02}'
 135 print(f'Using latest Bank of Canada data (from {ymd})', file=stderr)
 136 
 137 boc = 'https://www.bankofcanada.ca'
 138 base = boc + '/valet/observations/group/FX_RATES_DAILY/json'
 139 uri = base + f'?start_date={since.year:02}-{since.month:02}-{since.day:02}'
 140 
 141 with urlopen(uri) as c:
 142     # exchange rates for the latest day observed: a dictionary with keys like
 143     # FXAUDCAD, each of which leads to a dictionary where key 'v' gives the
 144     # exchange rate as a string
 145     data = loads(c.read())['observations'][-1]
 146     rates = {}
 147     for k in cur_list:
 148         rates[k] = data[f'FX{k}CAD']['v']
 149 
 150 # empty line to let output breathe a bit
 151 print(file=stderr)
 152 
 153 header = '        from                    to              rate      name'
 154 
 155 if currency != '' and currency in cur_list:
 156     # only change between CAD and the currency given
 157     x = amount
 158     k = currency
 159     v = float(rates[k])
 160     s = cur_names[k]
 161     print(header, file=stderr)
 162     print(f'\x1b[38;5;196m{x:15,.2f}\x1b[0m CAD = {x/v:15,.2f} {k} \x1b[38;5;208m{v:15,.4f}\x1b[0m {s}')
 163     print(f'{x:15,.2f} {k} = \x1b[38;5;196m{x*v:15,.2f}\x1b[0m CAD \x1b[38;5;208m{v:15,.4f}\x1b[0m {s}')
 164     exit(0)
 165 
 166 # calculate and show all currency exchanges, converting in both directions
 167 
 168 try:
 169     # exchange amounts forward
 170     print(header, file=stderr)
 171     for i, k in enumerate(cur_list):
 172         x = amount
 173         v = float(rates[k])
 174         s = cur_names[k]
 175         if i%10 < 5:
 176             print(f'\x1b[38;5;196m{x:15,.2f}\x1b[0m CAD = {x/v:15,.2f} {k} \x1b[38;5;208m{v:15,.4f}\x1b[0m {s}')
 177         else:
 178             print(f'\x1b[38;5;196m{x:15,.2f}\x1b[0m CAD = \x1b[38;5;244m{x/v:15,.2f} {k}\x1b[0m \x1b[38;5;208m{v:15,.4f}\x1b[0m \x1b[38;5;244m{s}\x1b[0m')
 179 
 180     # empty line to let output breathe a bit
 181     print(file=stderr)
 182 
 183     # exchange amounts backward
 184     # print(header, file=stderr)
 185     for i, k in enumerate(cur_list):
 186         x = amount
 187         v = float(rates[k])
 188         s = cur_names[k]
 189         if i%10 < 5:
 190             print(f'{x:15,.2f} {k} = \x1b[38;5;196m{x*v:15,.2f}\x1b[0m CAD \x1b[38;5;208m{v:15,.4f}\x1b[0m {s}')
 191         else:
 192             print(f'\x1b[38;5;244m{x:15,.2f} {k} =\x1b[0m \x1b[38;5;196m{x*v:15,.2f}\x1b[0m CAD \x1b[38;5;208m{v:15,.4f}\x1b[0m \x1b[38;5;244m{s}\x1b[0m')
 193 except BrokenPipeError:
 194     # avoid showing broken-pipe messages from stdlib, in case not all of this
 195     # script's output was needed
 196     stderr.close()