Log a daily yield curve spread and flag it against a threshold

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, the latest curve for three maturities
curl -s "https://api.narwhalapi.com/v1/economics/USA/yield-curve?maturity=3m,2y,10y" \
  -H "Authorization: Bearer $NARWHAL_API_KEY"
Response
{
  "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.

  1. Backfill a year backfill asks /history for 365 days with limit set to 1000 and follows next_cursor, so a new database starts with a full series.
  2. Compute each spread with Decimal record subtracts the short yield from the long one for each pair in PAIRS. Values stay as Decimal, so the subtraction has no float noise, and they are stored as text.
  3. Set a flag from the threshold flag returns INVERTED below zero, WATCH from zero up to WATCH, and OK above it. WATCH is 0.10 percentage points here and is the team's own number.
  4. Add the latest curve each day daily reads 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.
    Python, a stored spread series with flags
    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.

Output of the script
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 1

Turn the spread into a flag the team can act on

FlagRuleWhat the team does
OKSpread above the WATCH levelLog the row and send nothing
WATCHSpread from 0 up to the WATCH levelLog the row and tell the team the curve is close to flat
INVERTEDSpread below 0Log 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_percent values as received.
  • The spread for each pair and its flag.
  • The curve_type value, 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 .

Next steps