from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
import yfinance as yf
DATA = Path("../_data") if Path("../_data").exists() else Path("data")
HEADERS = {"User-Agent": "FIN 4506 course notebook"}
def cached(name, download):
"""Run download() once and save the result; later runs read the file."""
file = DATA / f"{name}.parquet"
if not file.exists():
DATA.mkdir(exist_ok=True)
download().to_frame(name).to_parquet(file)
return pd.read_parquet(file)[name]The USDC Depeg
This notebook looks at how well the two largest stablecoins, Tether’s USDT and Circle’s USDC, have held their one-dollar peg, and at the one weekend when USDC lost it: March 10 to 13, 2023, after the failure of Silicon Valley Bank, where Circle held 3.3 billion dollars of USDC’s reserves. We use daily data on supply and prices, and hourly prices for the weekend itself. The notes on stablecoins explain the background.
Packages
How big are stablecoins?
DefiLlama tracks the supply of every stablecoin, free and without an account. Its identifier for USDT is 1 and for USDC is 2.
def defillama_supply(query=""):
url = "https://stablecoins.llama.fi/stablecoincharts/all" + query
rows = requests.get(url, headers=HEADERS, timeout=60).json()
return pd.Series({pd.to_datetime(int(r["date"]), unit="s"): r["totalCirculatingUSD"]["peggedUSD"]
for r in rows if "peggedUSD" in r.get("totalCirculatingUSD", {})})
supply = pd.DataFrame({
"All stablecoins": cached("stablecoins_total", lambda: defillama_supply()),
"USDT": cached("stablecoins_usdt", lambda: defillama_supply("?stablecoin=1")),
"USDC": cached("stablecoins_usdc", lambda: defillama_supply("?stablecoin=2")),
})
(supply.iloc[-1] / 1e9).round(1)All stablecoins 289.5
USDT 175.7
USDC 70.1
Name: 2026-11-11 00:00:00, dtype: float64
How close to a dollar?
Yahoo Finance has daily prices for both coins. Before 2021 the prices it reports for stablecoins are noisy, so we start in 2021. We measure the deviation from the peg in basis points, hundredths of a percent.
prices = yf.download(["USDT-USD", "USDC-USD"], start="2021-01-01", end="2026-11-01",
auto_adjust=True, progress=False)
close = prices["Close"].rename(columns={"USDT-USD": "USDT", "USDC-USD": "USDC"})
deviation = 10_000 * (close - 1)
pd.DataFrame({
"days": close.count(),
"within 10 bp": (deviation.abs() <= 10).mean(),
"within 50 bp": (deviation.abs() <= 50).mean(),
"largest fall below $1 (bp)": deviation.min(),
}).style.format({"within 10 bp": "{:.1%}", "within 50 bp": "{:.1%}",
"largest fall below $1 (bp)": "{:.0f}"})| days | within 10 bp | within 50 bp | largest fall below $1 (bp) | |
|---|---|---|---|---|
| Ticker | ||||
| USDC | 2130 | 98.5% | 99.9% | -285 |
| USDT | 2130 | 88.0% | 99.9% | -41 |
fig, ax = plt.subplots(figsize=(8, 3.2))
deviation.plot(ax=ax, lw=0.8)
ax.axhline(0, color="0.5", lw=0.5)
ax.legend(title=None, frameon=False)
ax.set_xlabel("")
ax.set_ylabel("Deviation from $1 (bp)")
plt.show()Since 2021, both coins have closed within half a percent of a dollar on almost every day, and USDC within a tenth of a percent on most of them. The exception stands out: a fall of almost 300 basis points in USDC’s daily closing price in March 2023, while USDT traded above a dollar the same weekend, as money moved from the coin whose reserves were in doubt to the one whose reserves had not been at the failed bank. Daily closing prices also hide how far USDC fell during the weekend.
The weekend: hourly prices
Bitstamp, an exchange that trades USDC against the dollar, publishes hourly prices without an account. We take the hours from March 9 to 15, 2023 (the timestamps below are Unix time, seconds since 1970, in UTC).
def bitstamp_hourly():
start, end = 1678320000, 1678838400 # March 9 and March 15, 2023, midnight UTC
url = "https://www.bitstamp.net/api/v2/ohlc/usdcusd/"
params = {"step": 3600, "start": start, "end": end, "limit": (end - start) // 3600}
rows = requests.get(url, params=params, headers=HEADERS, timeout=60).json()["data"]["ohlc"]
hourly = pd.DataFrame(rows).astype(float)
hourly.index = pd.to_datetime(hourly["timestamp"].astype(int), unit="s")
return hourly["low"]
low = cached("bitstamp_usdc_hourly_low_202303", bitstamp_hourly)
print(f"Lowest price: {low.min():.4f} at {low.idxmin():%A, %B %d, %H:%M} UTC")Lowest price: 0.8053 at Saturday, March 11, 07:00 UTC
fig, ax = plt.subplots(figsize=(8, 3.5))
low.plot(ax=ax, lw=1)
# The news, by day (the exact hours are not shown)
events = [("2023-03-10", "2023-03-11", "Friday: SVB fails,\nCircle discloses\n$3.3 billion at SVB"),
("2023-03-12 21:00", "2023-03-13 04:00", "Sunday evening (U.S.):\ndeposits guaranteed")]
for start, end, label in events:
ax.axvspan(pd.Timestamp(start), pd.Timestamp(end), color="0.9")
ax.annotate(label, (pd.Timestamp(start), 0.83), xytext=(3, 0),
textcoords="offset points", fontsize=8)
ax.set_xlabel("Time (UTC)")
ax.set_ylabel("USDC price, hourly low")
plt.show()The shaded periods mark the two pieces of news, by day; the figure does not show the exact hours. On Friday, Silicon Valley Bank failed and Circle disclosed that 3.3 billion dollars of USDC’s reserves, about 8% of the total, were deposited there. On Sunday evening, U.S. regulators announced that all depositors would be made whole, and Circle stated that the money “will be fully available when U.S. banks open tomorrow morning” (Circle 2023). The price fell furthest early on Saturday, well below the 0.92 that would have been fair even if every dollar at the bank had been lost, and was close to a dollar again within hours of the announcement.
The run: redemptions
A depeg is visible in the price; a run shows up in the supply, as holders redeem coins for dollars and the coins are destroyed.
window = supply.loc["2023-02-01":"2023-05-31", ["USDC", "USDT"]] / 1e9
fig, ax = plt.subplots(figsize=(8, 3.5))
window.plot(ax=ax)
ax.axvline(pd.Timestamp("2023-03-10"), color="0.5", ls=":", lw=1)
ax.set_xlabel("")
ax.set_ylabel("Billions of dollars")
plt.show()
usdc = supply["USDC"]
for date in ["2023-03-10", "2023-03-31", "2023-04-30"]:
print(f"USDC supply on {date}: {usdc.loc[date] / 1e9:.1f} billion")USDC supply on 2023-03-10: 43.2 billion
USDC supply on 2023-03-31: 32.6 billion
USDC supply on 2023-04-30: 30.4 billion
The price recovered within days, but the supply kept falling for weeks: holders who had seen the peg break redeemed their coins once they could do so at par. Over the same weeks USDT grew. Some of the money that left USDC moved to the other large stablecoin, whose reserves had not been at the failed bank.
What we learned
- In normal times, the peg of the large reserve-backed stablecoins is tight: since 2021, USDC has closed within 10 basis points of a dollar on 98.5% of days and USDT on 88%, and both within 50 basis points on almost every day.
- The peg broke when part of the reserves became uncertain, and it broke by more than the reserves at risk could justify. That is consistent with a run, in which the last to redeem may bear the losses and everyone wants to be first, and with a market that could not reach the issuer until the banks opened on Monday.
- A government guarantee of the bank deposits, not anything the stablecoin did, ended the episode. The GENIUS Act would not have prevented it: deposits at insured banks still count as reserves, and the law gives holders priority in an issuer’s failure, not insurance.
Try it
- DAI, a crypto-collateralized stablecoin, held a large share of its collateral in USDC in March 2023. Download DAI’s daily price (
DAI-USDon Yahoo Finance) and check what happened to it that weekend. - How much did USDT’s supply grow between March 10 and April 30, 2023, compared with the fall in USDC’s supply?
- Using the hourly data, how many hours did USDC spend more than 1% below a dollar?