Run the month-end close for foreign-currency balances

A month-end close needs two rates per foreign currency: a closing rate for the balances and an average rate for the month's activity. Narwhal returns the month's dated daily rates in one request, so both figures and their audit trail come from the same rows.

Pull the month once and keep the dates

A controller closing a EUR ledger with USD and GBP balances needs the same answer on every rerun. That starts with a single dated pull per month. The request below asks for EUR as the base and both currencies at once, and it spans the turn of the month so the weekend gap is visible. The rate is the amount of each currency per one euro.

curl, EUR against USD and GBP over a month end
curl -s -H "Authorization: Bearer $NARWHAL_API_KEY" \
  "https://api.narwhalapi.com/v1/fx/rates/EUR/observations?currencies=USD,GBP&start_date=2026-10-29&end_date=2026-11-02"
Response
{
  "base": "EUR",
  "observations": [
    {"date": "2026-10-29", "rates": [{"currency": "USD", "rate": "1.1130"}, {"currency": "GBP", "rate": "0.83578"}]},
    {"date": "2026-10-30", "rates": [{"currency": "USD", "rate": "1.1172"}, {"currency": "GBP", "rate": "0.83380"}]},
    {"date": "2026-11-02", "rates": [{"currency": "USD", "rate": "1.1216"}, {"currency": "GBP", "rate": "0.83580"}]}
  ],
  "next_cursor": null
}

Each row has a date and one entry per currency, with the rate as a decimal string. Saturday 31 October and Sunday 1 November have no row, so the closing row for October is the one dated 2026-10-30. The rows carry no retrieval time and no source field, so the close log has to add its own.

Produce the closing and the average in one run

The script below runs the whole month. It fetches the rows, takes the last row as the closing rate and the mean of all rows as the average, and writes one log line per currency and rate type. It stops if next_cursor is not null, because a partial month would give a wrong average. EUR is the reporting currency here, so a foreign amount divides by the rate.

python, a repeatable close run
import csv, json, os, sys, urllib.request
from datetime import datetime, timezone
from decimal import Decimal

API = "https://api.narwhalapi.com"
BALANCES = {"USD": Decimal("300000"), "GBP": Decimal("80000")}  # foreign balances, reporting currency EUR

def close_run(start, end):
    path = f"/v1/fx/rates/EUR/observations?currencies=USD,GBP&start_date={start}&end_date={end}"
    req = urllib.request.Request(API + path, headers={"Authorization": f"Bearer {os.environ['NARWHAL_API_KEY']}"})
    data = json.load(urllib.request.urlopen(req))
    assert data["next_cursor"] is None, "more than one page"
    got = datetime.now(timezone.utc).isoformat(timespec="seconds")
    rows = []
    for ccy in BALANCES:
        series = [(o["date"], Decimal(r["rate"])) for o in data["observations"] for r in o["rates"] if r["currency"] == ccy]
        last_date, closing = series[-1]
        average = sum(r for _, r in series) / len(series)
        rows.append([end[:7], ccy, "closing", last_date, closing, len(series), got])
        rows.append([end[:7], ccy, "average", "", round(average, 5), len(series), got])
    return path, rows

path, rows = close_run(sys.argv[1], sys.argv[2])
print("# request:", path)
csv.writer(sys.stdout).writerows(rows)
for ccy, amount in BALANCES.items():
    c = next(r[4] for r in rows if r[1] == ccy and r[2] == "closing")
    a = next(r[4] for r in rows if r[1] == ccy and r[2] == "average")
    print(f"{ccy} {amount} = EUR {round(amount / c, 2)} at closing, EUR {round(amount / a, 2)} at average")
Output for October
# request: /v1/fx/rates/EUR/observations?currencies=USD,GBP&start_date=2026-10-01&end_date=2026-10-31
2026-10,USD,closing,2026-10-30,1.1172,22,2026-11-03T03:09:30+00:00
2026-10,USD,average,,1.12016,22,2026-11-03T03:09:30+00:00
2026-10,GBP,closing,2026-10-30,0.83380,22,2026-11-03T03:09:30+00:00
2026-10,GBP,average,,0.84282,22,2026-11-03T03:09:30+00:00
USD 300000 = EUR 268528.46 at closing, EUR 267818.88 at average
GBP 80000 = EUR 95946.27 at closing, EUR 94919.44 at average

For USD the closing rate is 1.1172 and the average is 1.12016, both from 22 rows. For GBP they are 0.83380 and 0.84282. The USD 300,000 balance is EUR 268,528.46 at the closing rate and EUR 267,818.88 at the average, a difference of EUR 709.58. The GBP 80,000 balance is EUR 95,946.27 at the closing rate and EUR 94,919.44 at the average, a difference of EUR 1,026.83. The gap shows why the close names the rate type next to every figure.

What the audit trail should hold

A reviewer should be able to rebuild any booked figure from the log alone. The table lists a minimum set of fields and where each one comes from.

Log fieldExampleSource
Period2026-10Your close calendar
Currency and baseUSD against EURResponse: base, currency
Rate typeclosing or averageYour policy
Rate date2026-10-30Response: date of the row used
Rate1.1172Response: rate, kept as a string
Row count22Count of rows returned
RequestThe full path and queryWhat your job sent
Retrieved atA UTC timestampYour job's clock
Run and preparerA run id and a nameYour close system

Store the rate as the string the API sent and the average at the precision you apply. The row count lets a reviewer see that the average covers every dated row and not a partial pull. Which rate type applies to which balance is the company's own accounting policy, so the log records the type and leaves the choice to the policy document.

Rerun a closed month and compare

A rerun of September, with the same script and the new dates, gives the output below.

Output for September
# request: /v1/fx/rates/EUR/observations?currencies=USD,GBP&start_date=2026-09-01&end_date=2026-09-30
2026-09,USD,closing,2026-09-30,1.1355,22,2026-11-03T03:09:30+00:00
2026-09,USD,average,,1.15132,22,2026-11-03T03:09:30+00:00
2026-09,GBP,closing,2026-09-30,0.85463,22,2026-11-03T03:09:30+00:00
2026-09,GBP,average,,0.85812,22,2026-11-03T03:09:30+00:00
USD 300000 = EUR 264200.79 at closing, EUR 260570.48 at average
GBP 80000 = EUR 93607.76 at closing, EUR 93227.05 at average

The September closing row is dated 2026-09-30 and has 22 rows, the same count as October. Compare a rerun with the figures already booked. If the rate, the date or the row count differs, hold the entry and review it before posting. The booked figures in the log stay the record, and a later request is only a check against them.

Which rate type does each balance use?

The company's own accounting policy decides, and this page does not state a standard's rule. Narwhal supplies both candidates from the same rows: the last row for a closing rate and the mean of all rows for an average. Write the policy reference into the run record so the choice is traceable.

What if the month ends on a weekend?

The closing row is the last day that has a rate. For October 2026 that is Friday 30 October, because the month ended on Saturday. Keep the row's date in the log so the entry shows that the 30th supplied the rate. If the policy calls for a different day, such as the first rate of the next month, the same pull covers it when the end date extends past the month.

Can the close use the convert endpoint?

Only for a spot check. GET /v1/fx/convert takes from, to and amount, has no date parameter, and returns the current rate with a timestamp. It cannot return a past month end, so a booked closing rate has to come from the dated rows.

How many requests does the close need?

One per base currency and month. The October request above returned both currencies together, and the page limit of 100 rows is more than any month needs. For a longer span, send next_cursor back as cursor until it is null. The how-to on calculating a monthly average covers the averaging step by step.

Published .

Next steps