A risk or portfolio team that watches the curve daily needs a stored spread series, one check per day, a threshold flag and a run log. Narwhal returns the US curve for any date range and for the latest date, so a script can backfill a year, add the newest curve and flag a spread that is inverted or near flat.
What a risk team needs besides a one-off answer
A risk or portfolio team that watches the curve every day needs four things: a series it owns, a check that runs once per day, a threshold that triggers a flag and a log that explains each flag afterwards. The API supplies the yields. The stored series, the threshold and the log belong to the team. The latest curve comes from GET /v1/economics/USA/yield-curve, and maturity limits it to the points the monitor uses.
curl -s "https://api.narwhalapi.com/v1/economics/USA/yield-curve?maturity=3m,2y,10y" \
-H "Authorization: Bearer $NARWHAL_API_KEY"{
"date": "2026-11-02",
"yields": [
{
"maturity": "3m",
"tenor": "P3M",
"yield_percent": "4.21"
},
{
"maturity": "2y",
"tenor": "P2Y",
"yield_percent": "4.59"
},
{
"maturity": "10y",
"tenor": "P10Y",
"yield_percent": "4.91"
}
],
"country": "USA",
"curve_type": "nominal_par"
}date is the source date of the curve, which can be a day behind the day the call is made, so key the stored series on it and never on the run date. yield_percent is a decimal string in percent. curve_type says how the source defines the curve, and a monitor should store it so a change in definition shows up in the log. The 10 year yield less the 2 year yield is 0.32 percentage points, and less the 3 month yield it is 0.70.
Keep one table with one row per date and pair
The script below fills a table from the history route and then adds the latest curve. The primary key is the date and the pair, and every write replaces a row, so running the script twice or restarting it after a crash ends in the same state.
- Backfill a year
backfillasks/historyfor 365 days withlimitset to 1000 and followsnext_cursor, so a new database starts with a full series. - Compute each spread with Decimal
recordsubtracts the short yield from the long one for each pair inPAIRS. Values stay asDecimal, so the subtraction has no float noise, and they are stored as text. - Set a flag from the threshold
flagreturns INVERTED below zero, WATCH from zero up toWATCH, and OK above it.WATCHis 0.10 percentage points here and is the team's own number. - Add the latest curve each day
dailyreads the single-date route and writes its row. The final lines print the count of dates, the latest spread and flag, and whether the flag changed from the previous date.import os import sqlite3 from datetime import date, datetime, timedelta, timezone from decimal import Decimal import requests BASE = "https://api.narwhalapi.com/v1/economics/USA/yield-curve" HEAD = {"Authorization": f"Bearer {os.environ['NARWHAL_API_KEY']}"} PAIRS = [("2y", "10y"), ("3m", "10y")] # long yield minus short yield WATCH = Decimal("0.10") # percentage points db = sqlite3.connect("curve.db") db.execute("""create table if not exists spread ( date text, pair text, spread text, flag text, curve_type text, logged_at text, primary key (date, pair))""") def flag(value): return "INVERTED" if value < 0 else "WATCH" if value <= WATCH else "OK" def record(day, yields, curve_type): y = {x["maturity"]: Decimal(x["yield_percent"]) for x in yields} for short, long in PAIRS: if short in y and long in y: s = y[long] - y[short] db.execute("insert or replace into spread values (?,?,?,?,?,?)", (day, f"{long}-{short}", str(s), flag(s), curve_type, datetime.now(timezone.utc).isoformat(timespec="seconds"))) def backfill(start, end): params = {"start": start, "end": end, "maturity": "3m,2y,10y", "limit": 1000} while True: r = requests.get(BASE + "/history", params=params, headers=HEAD, timeout=15) r.raise_for_status() body = r.json() for obs in body["observations"]: record(obs["date"], obs["yields"], body["curve_type"]) if not body.get("next_cursor"): return params["cursor"] = body["next_cursor"] def daily(): r = requests.get(BASE, params={"maturity": "3m,2y,10y"}, headers=HEAD, timeout=15) r.raise_for_status() body = r.json() record(body["date"], body["yields"], body["curve_type"]) return body["date"] backfill((date.today() - timedelta(days=365)).isoformat(), date.today().isoformat()) latest = daily() db.commit() for pair in ("10y-2y", "10y-3m"): rows = db.execute("select date, spread, flag from spread where pair=? order by date", (pair,)).fetchall() watch = sum(1 for _, _, f in rows if f == "WATCH") prev, now = rows[-2], rows[-1] change = "flag changed" if prev[2] != now[2] else "flag unchanged" print(f"{pair}: {len(rows)} dates, latest {now[0]} spread {now[1]} {now[2]} ({change}), WATCH days {watch}")
The first run and a second run straight after it printed the same two lines and left 500 rows, which is 250 dates for each of the two pairs. The series has fewer dates than calendar days because weekends and market holidays have no row. The one WATCH day was on 2025-11-26, when 10y minus 3m was 0.08.
10y-2y: 250 dates, latest 2026-11-02 spread 0.32 OK (flag unchanged), WATCH days 0
10y-3m: 250 dates, latest 2026-11-02 spread 0.70 OK (flag unchanged), WATCH days 1Turn the spread into a flag the team can act on
| Flag | Rule | What the team does |
|---|---|---|
| OK | Spread above the WATCH level | Log the row and send nothing |
| WATCH | Spread from 0 up to the WATCH level | Log the row and tell the team the curve is close to flat |
| INVERTED | Spread below 0 | Log the row and raise the alert |
Send a message when the flag changes between two dates, not on every run while it stays the same. The last line of the script output shows that comparison. Name the pair in the message, because 10y minus 2y and 10y minus 3m can sit in different flags on the same day.
What to write in the run log
- The source date of the curve and the date and time of the run.
- The maturities used and the
yield_percentvalues as received. - The spread for each pair and its flag.
- The
curve_typevalue, so a change of definition is visible. - The number of rows written, and any HTTP error with the request that caused it.
A failed request raises an error and writes no row. That leaves a gap in the series that the next run fills, since the history route returns the missing dates.
What if the run happens before the new date is published?
The single-date route returns the newest source date. If that is still yesterday's date, the row is written again with the same values and nothing changes. A later run picks up the new date.
Can the same monitor cover another country?
The route takes a three letter country code. Maturities differ by country, so request the country without maturity first and read the maturities it lists. A maturity the source lacks returns 400 with the code invalid_query, and a country with no curve returns 404 with the code series_not_found.
How often should the check run?
The curve is published for one date at a time, so one run per day after the new date appears is enough. Schedule it as in schedule a daily data pull.
Published .