I am thinking about the possibility of buying silver and/or gold, and want to explain my thinking here so that others may inspect my rationale.
I have been earning an okay city wage for over a year now, and I have paid off debts I had accumulated whilst studying my PhD (that I will one day finish). I now have money sitting in the bank, and I am coming close to exhausting all investment options that they offer.
Even so, I have several concerns holding money in the bank:
There are many other reasons, but these will suffice for now.
The final objective is to store wealth in a robust way, that could be later be used to make a large purchase, such as a house.
Like any good project, we should define some research questions:
The first thing we can do is grab the historic values for silver and gold (using this website) as 20 year tracking (the largest they provide).
Note: In 2010 there were two January closes, skipping February and onto March. Given the low resolution we treat the data, the of January 2010 was manually set to of February 2010. In the grand scheme of things it makes no difference to the result.
0001 import csv 0002 from datetime import datetime 0003 import math 0004 # Process silver closing prices 0005 history_silver = { "date": [], "close": [] } 0006 with open("silver-and-gold/AGX-USD-2592000-20241014230807.csv") as silver_csv : 0007 silver_reader = csv.reader(silver_csv, delimiter = ",", quotechar= '"') 0008 for row in silver_reader : 0009 if row[0] == "Date" : continue 0010 row[0] = datetime.strptime(row[0], "%H:%M:%S %d-%b-%Y") 0011 for i in range(1, len(row)) : 0012 if len(row[i]) > 0 : row[i] = float(row[i]) 0013 history_silver["date"] += [ row[0].year + ((1 / 12.0) * (row[0].month - 1)) ] 0014 history_silver["close"] += [ math.log(row[3], 10) ] 0015 # Process gold closing prices 0016 history_gold = { "date": [], "close": [] } 0017 with open("silver-and-gold/AUX-USD-2592000-20241014230833.csv") as gold_csv : 0018 gold_reader = csv.reader(gold_csv, delimiter = ",", quotechar= '"') 0019 for row in gold_reader : 0020 if row[0] == "Date" : continue 0021 row[0] = datetime.strptime(row[0], "%H:%M:%S %d-%b-%Y") 0022 for i in range(1, len(row)) : 0023 if len(row[i]) > 0 : row[i] = float(row[i]) 0024 history_gold["date"] += [ row[0].year + ((1 / 12.0) * (row[0].month - 1)) ] 0025 history_gold["close"] += [ math.log(row[3], 10) ] 0026 # Plot that graph 0027 p = Plott( 0028 title = "Gold and Silver vs USD", 0029 x_title = "Date (as float)", 0030 y_title = "Close (USD, log base 10)", 0031 fg = "#FFF", 0032 bg = "#222" 0033 ) 0034 p.plot(history_silver["date"], history_silver["close"], "Silver", colour = "#C0C0C0") 0035 p.plot(history_gold["date"], history_gold["close"], "Gold", colour = "#FFD700") 0036 print(p.to_uri())
Now we want a function to be able to retrieve the price of silver or gold given a year or month:
0037 # get_close() 0038 # 0039 # For a given year, get the closing value for a given metal. 0040 # 0041 # @param metal The chosen metal, silver or gold. 0042 # @param year The year to be checked. 0043 # @param month The month to be checked. 0044 # @return The closing value for the given metal, or None if not found. 0045 def get_close(metal = "silver", year = 2024, month = 1) : 0046 close = None 0047 date = year + ((1 / 12.0) * (month - 1)) 0048 data = { "date": [], "close": [] } 0049 if metal == "silver" : 0050 data["date"] = history_silver["date"] 0051 data["close"] = history_silver["close"] 0052 elif metal == "gold" : 0053 data["date"] = history_gold["date"] 0054 data["close"] = history_gold["close"] 0055 for i in range(len(data["date"])) : 0056 if data["date"][i] == date : 0057 close = 10 ** data["close"][i] 0058 break 0059 return close 0060 0061 print("January 2007 gold was " + str(get_close("gold", 2007, 1))) 0062 print("January 2007 silver was " + str(get_close("silver", 2007, 1)))
January 2007 gold was 21498.709999999985 January 2007 silver was 449.4699999999999
Manually inspecting the result, this looks good!
For this, I manually had to pull the values from the Royal Mint. I wanted to use their JSON API, but it literally returns HTML! There are two raw files of text I scraped, one for silver and one for gold.
This isn’t perfect, but it’s the easiest data to collect. We parse it like so:
0063 # read_royal_mint() 0064 # 0065 # Read the text manually copied and pasted from the Royal Mint product 0066 # listings. A reasonable attempt is then made to parse the data and pull out 0067 # important information. 0068 # 0069 # @param files The files to be parsed. 0070 # @return The parsed data. 0071 def read_royal_mint(files = []) : 0072 weights = [ 0073 " 1/4oz ", 0074 " 1oz ", " 2oz ", " 5oz ", " 10oz ", " 20oz ", " 50oz ", " 100oz ", 0075 " 1g ", " 2g ", " 5g ", " 10g ", " 20g ", " 50g ", " 100g ", " 500g ", " 1kg ", 0076 " Kilo ", 0077 " 50p ", " £5 ", 0078 " $2.5 ", " $5 ", " $10 ", " $20 ", 0079 " Sixpence ", " Dollar ", "Penny", "Quarter", "Half", " Francs ", 0080 "Sovereign", "Coin", "Medal", "Set", "Crown", "Guinea", "Unite", "Mohur", "Shield", "Africa", "Birthday", 0081 ] 0082 data = [] 0083 group = { "break": False, "line": 1, "other": [] } 0084 for f in files : 0085 with open(f) as file : 0086 line_no = 1 0087 for line in file : 0088 line = line.strip() 0089 if not group["break"] : 0090 if len(line) <= 0 : 0091 if not "type" in group : group["type"] = "Unknown" 0092 if not "title" in group : group["title"] = "Unknown" 0093 if not "title_full" in group : group["title_full"] = "Unknown" 0094 if not "edition" in group : group["edition"] = "Unknown" 0095 if "price" in group : group.pop("price") 0096 group["break"] = True 0097 elif not "type" in group : 0098 group["type"] = line 0099 if "Silver" in line : group["metal"] = "Silver" 0100 elif "Gold" in line : group["metal"] = "Gold" 0101 else : 0102 #print(f + "::" + str(line_no) + " Unknown metal '" + line + "'") 0103 group["metal"] = "Unknown" 0104 elif not "title" in group : 0105 # Sometimes the type is not provided 0106 if line.startswith(group["type"]) : 0107 group["title_full"] = line 0108 group["title"] = group["type"] 0109 group["metal"] = "Unknown" 0110 group["type"] = "Unknown" 0111 #print(f + "::" + str(line_no) + " Unknown metal (late)") 0112 else : 0113 group["title"] = line 0114 elif not "title_full" in group and line.startswith(group["title"]) : 0115 group["title_full"] = line 0116 elif not "title_full" in group : 0117 group["other"] += [ line ] 0118 else : 0119 group["other"] += [ line ] 0120 else : 0121 if not "price" in group and line.startswith("Price") : 0122 group["price"] = line 0123 group["price_raw"] = float(line.split(" ")[-1].replace(",", "")[1:]) 0124 elif "price" in group : # Group collected 0125 group["stock"] = line 0126 # Pull out the weight 0127 msg = group["title"] 0128 if "title_full" in group : msg += " " + group["title_full"] 0129 for weight in weights : 0130 if weight in msg : 0131 group["weight"] = weight.strip() 0132 break 0133 if not "weight" in group : 0134 print(f + "::" + str(line_no) + " No weight found: " + str(group)) 0135 group["weight"] = "Unknown" 0136 # Store it 0137 data += [ group ] 0138 group = { "break": False, "line": line_no + 1, "other": [] } 0139 else : print(f + "::" + str(line_no) + " Parse failure '" + line + "'") 0140 line_no += 1 0141 return data 0142 0143 # Process files 0144 royal_mint = read_royal_mint(["silver-and-gold/royal_mint_silver_2024_10_15.txt", "silver-and-gold/royal_mint_gold_2024_10_15.txt"])
silver-and-gold/royal_mint_gold_2024_10_15.txt::676 No weight found: {'break': True, 'line': 670, 'other': ['The Great Seals of the Realm - King Henry VIII - 1oz Fine Gold', 'Limited Edition 175'], 'type': 'The Great Seals of the Realm - King Henry VIII - 1oz Fine Gold', 'metal': 'Gold', 'title': 'only 175 available', 'title_full': 'Unknown', 'edition': 'Unknown', 'price': 'Price: £3,500.00', 'price_raw': 3500.0, 'stock': 'In Stock'}
There are some errors and it is not a great parsing, but it will do. Next let’s get some data.
0145 royal_mint_silver = [] 0146 royal_mint_gold = [] 0147 royal_mint_rejected = [] 0148 for r in royal_mint : 0149 if r["metal"] == "Silver" or r["metal"] == "Gold" : 0150 if r["weight"] == "1/4oz" : 0151 r["weight_kg"] = 0.02834952 * 0.25 0152 elif r["weight"].endswith("oz") : 0153 # NOTE: Calculation is in troy ounces! 0154 r["weight_kg"] = float(r["weight"][:-2]) * 0.0311034768 0155 elif r["weight"] == "Kilo" : 0156 r["weight_kg"] = 1 0157 elif r["weight"].endswith("kg") : 0158 r["weight_kg"] = float(r["weight"][:-2]) 0159 elif r["weight"].endswith("g") : 0160 r["weight_kg"] = float(r["weight"][:-1]) * 0.001 0161 if not "weight_kg" in r : royal_mint_rejected += [ r ] 0162 else : 0163 if r["metal"] == "Silver" : royal_mint_silver += [ r ] 0164 elif r["metal"] == "Gold" : royal_mint_gold += [ r ] 0165 else : royal_mint_rejected += [ r ] 0166 else : royal_mint_rejected += [ r ] 0167 0168 print("Useful data: " + str( 0169 100.0 * ( 0170 (len(royal_mint_silver) + len(royal_mint_gold)) / 0171 (len(royal_mint_silver) + len(royal_mint_gold) + len(royal_mint_rejected)) 0172 )) + "%")
Useful data: 49.72067039106145%
Near enough 50% is usable, that’ll do. Let’s process this data:
0173 rm_silver_cost = [] 0174 rm_silver_weight = [] 0175 rm_gold_cost = [] 0176 rm_gold_weight = [] 0177 for r in royal_mint_silver : 0178 rm_silver_cost += [ math.log(r["price_raw"], 10) ] 0179 rm_silver_weight += [ math.log(r["weight_kg"], 10) ] 0180 for r in royal_mint_gold : 0181 rm_gold_cost += [ math.log(r["price_raw"], 10) ] 0182 rm_gold_weight += [ math.log(r["weight_kg"], 10) ] 0183 # Plot that graph 0184 p = Plott( 0185 title = "Gold and Silver: Weight per Cost", 0186 x_title = "Cost (log base 10 GBP)", 0187 y_title = "Weight (log base 10 kg)", 0188 fg = "#FFF", 0189 bg = "#222" 0190 ) 0191 p.plot(rm_silver_cost, rm_silver_weight, "Silver", colour = "#C0C0C0") 0192 p.plot(rm_gold_cost, rm_gold_weight, "Gold", colour = "#FFD700") 0193 print(p.to_uri())
To get good value for money, you want the largest weight possible with the lowest cost possible - or put more simply, the best weight per cost ratio. Let’s see it:
0194 def sort_key_kg_gbp(e) : 0195 return e["weight_kg"] / e["price_raw"] 0196 0197 royal_mint_silver.sort(key = sort_key_kg_gbp, reverse = True) 0198 royal_mint_gold.sort(key = sort_key_kg_gbp, reverse = True) 0199 0200 print("Weight\tCost\tg/GBP\tTitle") 0201 for r in (royal_mint_silver[:5] + royal_mint_gold[:5]) : 0202 print( 0203 str(round(r["weight_kg"], 3)) + "kg\t£" + 0204 str(int(r["price_raw"])) + "\t" + 0205 str(round(r["weight_kg"] * 1000 / r["price_raw"], 5)) + "\t" + 0206 r["title"] 0207 )
Weight Cost g/GBP Title 0.311kg £273 1.13616 The Royal Tudor Beasts 2024 Tudor Dragon Silver 10oz Bullion Coin 0.311kg £273 1.13616 The Royal Tudor Beasts 2024 Seymour Unicorn 10oz Silver Bullion Coin 0.311kg £273 1.13616 Little John 2024 10oz Silver Bullion Coin 0.031kg £28 1.10413 Best Value 1oz Silver Bullion Coin 0.031kg £28 1.10413 The Best Value Britannia 1oz Silver Bullion Coin 1.0kg £66906 0.01495 1kg Gold Bullion Cast Bar 0.5kg £33518 0.01492 500g Gold Bullion Cast Bar 0.031kg £2085 0.01492 Best Value Gold 1oz Bullion Coin 0.031kg £2117 0.01469 Britannia 2025 1oz Gold Bullion Coin 0.031kg £2117 0.01469 Britannia 2024 1oz Gold Bullion Coin
This was a useful exercise! I realised the following:
These values overlayed on the original graph:
0208 rm_best_silver_cost = [] 0209 rm_best_silver_weight = [] 0210 rm_best_gold_cost = [] 0211 rm_best_gold_weight = [] 0212 for r in royal_mint_silver[:5] : 0213 rm_best_silver_cost += [ math.log(r["price_raw"], 10) ] 0214 rm_best_silver_weight += [ math.log(r["weight_kg"], 10) ] 0215 for r in royal_mint_gold[:5] : 0216 rm_best_gold_cost += [ math.log(r["price_raw"], 10) ] 0217 rm_best_gold_weight += [ math.log(r["weight_kg"], 10) ] 0218 p.plot(rm_best_silver_cost, rm_best_silver_weight, "Silver (Best)", colour = "#FF0088") 0219 p.plot(rm_best_gold_cost, rm_best_gold_weight, "Gold (Best)", colour = "#00CC00") 0220 print(p.to_uri())
And we can see we have correctly pulled out the most top left values quite well.
An interesting fact is that with gold you don’t pay VAT, and is CGT exempt on coins, whereas with silver you pay VAT, but don’t pay CGT. Let us write a function to allow us to predict the potential gains (or losses) given some simulations:
0221 vat = 0.2 # 20% 0222 cgt = 0.28 # 28% 0223 cgt_limit = 12300 0224 0225 # sim_buy_sell() 0226 # 0227 # Simulate buying and selling of precious metals using historic data. 0228 # 0229 # @param metal The precious metal being traded, silver or gold. 0230 # @param kg The amount of precious metal being traded. 0231 # @param buy_year The buy-in year. 0232 # @param buy_month The buy-in month. 0233 # @param sell_year The sell-out year. 0234 # @param sell_month The sell-out month. 0235 # @return Key metrics, including the buy-in price, the sell-out price, the 0236 # costs in trading (if it applies) and the profist/loss difference made 0237 # overall. 0238 def sim_buy_sell(metal = "silver", kg = 1, buy_year = 2024, buy_month = 1, sell_year = 2024, sell_month = 1) : 0239 buy_market = get_close(metal, buy_year, buy_month) 0240 sell_market = get_close(metal, sell_year, sell_month) 0241 ratio_market = sell_market / buy_market 0242 costs = 0 0243 buy_price = 0 0244 if metal == "silver" : 0245 buy_price = (royal_mint_silver[0]["price_raw"] / royal_mint_silver[0]["weight_kg"]) * kg 0246 costs += buy_price * vat 0247 elif metal == "gold" : 0248 buy_price = (royal_mint_gold[0]["price_raw"] / royal_mint_gold[0]["weight_kg"]) * kg 0249 # NOTE: We avoid CGT for both as coins. 0250 sell_price = ratio_market * buy_price 0251 return { "buy": buy_price, "sell": sell_price, "diff": sell_price - (buy_price + costs), "costs": costs } 0252 0253 print(sim_buy_sell("silver", 1, 2007, 1, 2024, 1)) 0254 print(sim_buy_sell("gold", 0.01325, 2007, 1, 2024, 1))
{'buy': 880.1588380627595, 'sell': 1474.829530084248, 'diff': 418.63892440893665, 'costs': 176.0317676125519} {'buy': 886.5088725, 'sell': 2669.2656048860813, 'diff': 1782.7567323860812, 'costs': 0}
We assume that we buy in at ~£880 in January 2007 and sell January 2024. I roughly balanced the two metals buy value (not considering tax). We see that our return on silver is ~50%, but our return on gold is ~200%. Of that profit, as it is CGT exempt, it is non-taxable too!
Possibly more useful is being able to define an investment gbp
, i.e. an investment available, which would account for any payable taxes.
Normally tax is calculated at (where (20%)):
Instead we know how much we want our total investment to be, so we calculate as follows:
0255 # sim_invest() 0256 # 0257 # Similar to sim_buy_sell(), except we set an investment amount rather than 0258 # weight. We then simulate buying and selling of precious metals using historic 0259 # data. 0260 # 0261 # @param metal The precious metal being traded, silver or gold. 0262 # @param gbp The total investment, including incurred costs. 0263 # @param buy_year The buy-in year. 0264 # @param buy_month The buy-in month. 0265 # @param sell_year The sell-out year. 0266 # @param sell_month The sell-out month. 0267 # @return Key metrics, including the buy-in price, the sell-out price, the 0268 # costs in trading (if it applies) and the profist/loss difference made 0269 # overall. 0270 def sim_invest(metal = "silver", gbp = 1000, buy_year = 2024, buy_month = 1, sell_year = 2024, sell_month = 1) : 0271 kg = 0 0272 if metal == "silver" : 0273 kg = (royal_mint_silver[0]["weight_kg"] / royal_mint_silver[0]["price_raw"]) * gbp 0274 kg = kg / (1.0 + vat) 0275 elif metal == "gold" : 0276 kg = (royal_mint_gold[0]["weight_kg"] / royal_mint_gold[0]["price_raw"]) * gbp 0277 return sim_buy_sell(metal, kg, buy_year, buy_month, sell_year, sell_month) 0278 0279 print(sim_invest("silver", 1000, 2007, 1, 2024, 1)) 0280 print(sim_invest("gold", 1000, 2007, 1, 2024, 1))
{'buy': 833.3333333333333, 'sell': 1396.3668320466318, 'diff': 396.3668320466319, 'costs': 166.66666666666666} {'buy': 1000.0, 'sell': 3010.9857754255936, 'diff': 2010.9857754255936, 'costs': 0}
We see gold has a buy-in of £1000, whereas silver has a buy-in of ~£833 + ~£167 of costs for a total of £1000. Great!
The market data goes back to February 2005, and up to October 2024. We can make some predictions about different holding times based on historic data.
Additionally, let’s consider how the money may fair in the bank with some simplistic flat interest returns:
Note: Money made over a CGT threshold in interest is subject to CGT (capital gains tax), especially these high-interest accounts where stocks and investments are concerned.
Let’s write a method to plot multi-year holdings and simulate!
0281 # plot_hold() 0282 # 0283 # A helper function for plotting the outcome of holding onto precious metals 0284 # over time given historic data. 0285 # 0286 # @param beg_year The year to run the simulations from. 0287 # @param beg_month The month to run the simulations from. 0288 # @param end_year The year to run the simulations to. 0289 # @param end_month The month to run the simulations to. 0290 # @param hold_years The number of years to hold the precious metal before selling. 0291 # @param bank_interest Compare against leaving money in the bank at different rates. 0292 def plot_hold(beg_year = 2005, beg_month = 2, end_year = 2024, end_month = 10, hold_years = 2, bank_interest = [ 0.005, 0.01, 0.02, 0.05 ]) : 0293 investment = 1000 0294 silver_x = [] 0295 silver_y = [] 0296 gold_x = [] 0297 gold_y = [] 0298 zero_y = [] 0299 # Calculate metals investment 0300 for year in range(beg_year, end_year + 1 - hold_years) : 0301 for month in range(1, 12 + 1) : 0302 if beg_year == year and beg_month > month : continue 0303 if end_year - hold_years == year and end_month < month : break 0304 x = year + ((1.0 / 12) * (month - 1)) 0305 silver_predict = sim_invest("silver", investment, year, month, year + hold_years, month) 0306 silver_x += [ x ] 0307 if silver_predict["diff"] >= 0 : silver_y += [ math.log(silver_predict["diff"], 10) ] 0308 elif silver_predict["diff"] == 0 : silver_y += [ silver_predict["diff"] ] 0309 else : silver_y += [ -math.log(-silver_predict["diff"], 10) ] 0310 gold_predict = sim_invest("gold", investment, year, month, year + hold_years, month) 0311 gold_x += [ x ] 0312 if gold_predict["diff"] >= 0 : gold_y += [ math.log(gold_predict["diff"], 10) ] 0313 elif gold_predict["diff"] == 0 : gold_y += [ gold_predict["diff"] ] 0314 else : gold_y += [ -math.log(-gold_predict["diff"], 10) ] 0315 zero_y = [ 0 ] * len(silver_x) 0316 bank = [] 0317 # Show equivalent simplisitc bank investment 0318 for interest in bank_interest : 0319 i = investment 0320 for year in range(hold_years) : 0321 i *= 1.0 + interest 0322 profit = i - investment 0323 profit -= profit * cgt # Apply CGT (capital gains tax) 0324 bank += [ [ math.log(profit, 10) ] * len(silver_x) ] 0325 # Plot that data 0326 p = Plott( 0327 title = "Gold and Silver: " + str(hold_years) + " Year Hold Historic Predictions", 0328 x_title = "Buy Year", 0329 y_title = "Profit (log base 10 GBP)", 0330 fg = "#FFF", 0331 bg = "#222" 0332 ) 0333 p.plot(silver_x, zero_y, "Zero line", colour = "#FF0000") 0334 p.plot(silver_x, silver_y, "Silver", colour = "#C0C0C0") 0335 p.plot(gold_x, gold_y, "Gold", colour = "#FFD700") 0336 for z in range(len(bank)) : 0337 p.plot(silver_x, bank[z], "Bank flat interest " + str(round(bank_interest[z] * 100, 2)) + "%") 0338 print(p.to_uri()) 0339 return
Now let’s take a look!
0340 plot_hold(hold_years = 1)
Holding onto silver or gold for just a year does not seem wise. Unless you are lucky at you buy-in, you could very easily make a loss after holding for just a year. Why is this important? It means you are unlikely confidently hold for just a year and be able to liquidate your investment if you need it.
Over just a year, holding silver or gold could even be a worse investment than considering a fixed-interest investment with the bank.
0341 plot_hold(hold_years = 2)
With a two year hold on silver and gold it becomes clearer that gold falls last and recovers first. Events where silver outperforms gold exist, but are rare. You could be left holding silver or gold for a long time, which may represent significantly less value than banking the money.
0342 plot_hold(hold_years = 5)
When holding for 5 years, it seems clear that there could be large periods where you are unable to sell your investment without making a significant loss. Gold is begins to display significantly greater stability too. If you find yourself holding during a recession, you may be holding for a long time, especially if the buy-in was wrong.
Interestingly, the best thing you could probably do is double-down on your position and buy the metals at the new lower price.
0343 plot_hold(hold_years = 10)
Holding gold over a period of 10 years is far more stable in gold than silver. Gold represents nearly as good an investment as a fixed high interest account.
0344 plot_hold(hold_years = 15)
Holding gold for longer than 15 years is almost always a better investment than a high interest account. Bare in mind that this doesn’t account for risk, something that our historic simulations attempt to account for!
Let us consider each of the original research questions…
- Which precious metal realises a greater return on investment, silver or gold?
Other than some weird short-term edge-cases, it appears that gold is consistently a better investment than silver, especially when holding for two years or more.
- How historically volatile is each metal, when compared to a fixed-interest rate with a bank?
We have ultimately made a massive assumption with the interest rates that banks can provide, and yet have a relatively realistic expectation of how volatile silver and gold can be expected to be.
- How long may each metal need to be held to safely realise a profit on investment?
It really depends on the buy-in market. With a 5 year hold the market is relatively stable, but this position could translate to a 10 year or 15 year hold to realise full gains.
If I do invest in silver and gold, it will be a case of watching the stock market and investing at a good price. Ideally I would wait for a drop in the price of precious metals.