From f420296535cad8fa56e0b5719d4d25be4de2cd7e Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Mon, 5 Oct 2026 13:55:57 +1100 Subject: [PATCH 1/3] Add fxmacrodata module --- modules/README.md | 6 ++ modules/fxmacrodata/README.md | 52 +++++++++ modules/fxmacrodata/mod.nu | 191 ++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 modules/fxmacrodata/README.md create mode 100644 modules/fxmacrodata/mod.nu diff --git a/modules/README.md b/modules/README.md index 28afd3bc2..4e3c2cd02 100644 --- a/modules/README.md +++ b/modules/README.md @@ -12,6 +12,7 @@ - [filesystem](#filesystem) - [formats](#formats) - [fun](#fun) + - [fxmacrodata](#fxmacrodata) - [github](#github) - [gitlab](#gitlab) - [jc](#jc) @@ -110,6 +111,11 @@ Examples of input/output formatters: - [wordle](./fun/wordle.nu) - A Terminal Wordle game. The code is based on this [gist](https://gist.github.com/huytd/6a1a6a7b34a0d0abcac00b47e3d01513), but slightly personalized. +## [fxmacrodata](./fxmacrodata/) + +Economic indicator releases, release calendars and FX rates from the FXMacroData API as Nushell tables. +see [README](./fxmacrodata/) + ## github - [branch-protections](../sourced/github/branch-protections/) - Do you have hundreds or thousands of GitHub repositories in your organization? Are you tired of manually managing their branch protection rules? Don't! Let nushell do it for you! see [README](../sourced/github/branch-protections/README.md) diff --git a/modules/fxmacrodata/README.md b/modules/fxmacrodata/README.md new file mode 100644 index 000000000..8a2dc2065 --- /dev/null +++ b/modules/fxmacrodata/README.md @@ -0,0 +1,52 @@ +# fxmacrodata + +Commands for the [FXMacroData](https://fxmacrodata.com/?utm_source=github&utm_medium=referral&utm_campaign=nu_scripts&utm_content=readme) REST API: economic indicator releases (CPI, payrolls, GDP, policy rates, ...), release calendars and FX spot rates, returned as Nushell tables with `datetime` columns. + +```nushell +use modules/fxmacrodata +``` + +| Command | Description | +|---------|-------------| +| `fxmacrodata catalogue [currency]` | indicators available for a currency (default `usd`) | +| `fxmacrodata history ` | release history for one indicator, most recent first. `--start`, `--end` (YYYY-MM-DD), `--limit` | +| `fxmacrodata calendar [currency]` | upcoming scheduled releases. `--indicator` to filter | +| `fxmacrodata forex ` | daily FX spot rates. `--start`, `--end`, `--limit` (needs an API key) | + +Every command accepts `--raw` to return the unmodified JSON response instead of a table. + +## API key + +USD indicators and the USD calendar work without a key. Keyless history covers roughly the last 90 days and new releases show up after a short delay; when that applies a notice is printed to stderr. Other currencies and `forex` need a key: + +```nushell +$env.FXMACRODATA_API_KEY = "..." +``` + +The key is only ever sent in the `X-API-Key` request header. + +## Examples + +```nushell +> fxmacrodata history usd inflation --limit 5 | update date { format date "%Y-%m-%d" } +fxmacrodata: Anonymous access returns the most recent 90 days. Supply an API key for the full history. +fxmacrodata: Free access is delayed by 15 minutes. Releases published in the last 15 minutes are withheld. An Individual or Business API key returns them in real time. +╭───┬────────────┬───────┬──────────┬──────────────┬────────╮ +│ # │ date │ value │ previous │ released │ source │ +├───┼────────────┼───────┼──────────┼──────────────┼────────┤ +│ 0 │ 2026-08-31 │ 3.40 │ 3.40 │ 3 weeks ago │ BLS │ +│ 1 │ 2026-07-31 │ 3.40 │ │ 2 months ago │ BLS │ +╰───┴────────────┴───────┴──────────┴──────────────┴────────╯ + +> fxmacrodata calendar usd --indicator inflation +╭───┬──────────────┬───────────┬─────────────────┬────────────┬───────────╮ +│ # │ release_time │ indicator │ name │ importance │ confirmed │ +├───┼──────────────┼───────────┼─────────────────┼────────────┼───────────┤ +│ 0 │ in a week │ inflation │ Inflation (CPI) │ high │ true │ +│ 1 │ in a month │ inflation │ Inflation (CPI) │ high │ true │ +│ 2 │ in 2 months │ inflation │ Inflation (CPI) │ high │ true │ +╰───┴──────────────┴───────────┴─────────────────┴────────────┴───────────╯ + +# high-importance US releases in the next two weeks +> fxmacrodata calendar usd | where importance == high and release_time < ((date now) + 2wk) +``` diff --git a/modules/fxmacrodata/mod.nu b/modules/fxmacrodata/mod.nu new file mode 100644 index 000000000..5a832a0ba --- /dev/null +++ b/modules/fxmacrodata/mod.nu @@ -0,0 +1,191 @@ +# FXMacroData API wrapper +# +# Economic indicator releases, release calendars and FX spot rates from +# https://api.fxmacrodata.com, returned as Nushell tables. +# +# USD data works without an API key. Other currencies and the FX endpoint need +# a key, which is read from `$env.FXMACRODATA_API_KEY` and sent in the +# `X-API-Key` header. + +const BASE_URL = "https://api.fxmacrodata.com/v1" + +def "nu-complete fxmacrodata currencies" [] { + [ + aud brl cad chf cnh cny dkk eur gbp huf ils + jpy krw myr ngn nok nzd pen sek thb twd usd + ] +} + +def api-headers []: nothing -> record { + let key = $env.FXMACRODATA_API_KEY? | default "" + if ($key | is-empty) { {} } else { {X-API-Key: $key} } +} + +# GET a path under /v1, dropping null query parameters +def api-get [path: string, query: record = {}]: nothing -> any { + let params = $query + | transpose key value + | where value != null + | each {|p| {key: $p.key, value: ($p.value | into string)} } + let url = if ($params | is-empty) { + $"($BASE_URL)($path)" + } else { + $"($BASE_URL)($path)?($params | transpose -r -d | url build-query)" + } + + let response = http get --full --allow-errors --headers (api-headers) $url + if $response.status >= 400 { + let detail = if ($response.body | describe) =~ "^record" { + $response.body.detail? | default ($response.body | to json -r) + } else { + $response.body | into string + } + error make --unspanned { + msg: $"FXMacroData request failed with HTTP ($response.status): ($detail)" + } + } + $response.body +} + +# Print free-tier notices (history window, release delay) to stderr +def report-free-tier [body: record] { + let window = $body.freemium_window? | default {} + if ($window.applied? | default false) { + print --stderr $"fxmacrodata: ($window.message? | default 'free tier history is limited')" + } + let delay = $body.freemium_delay? | default {} + if ($delay.applied? | default false) { + let withheld = $delay.withheld_count? | default 0 + let suffix = if $withheld > 0 { $" \(($withheld) release\(s\) withheld\)" } else { "" } + print --stderr $"fxmacrodata: ($delay.message? | default 'free tier data is delayed')($suffix)" + } +} + +def epoch-to-datetime []: any -> any { + if $in == null { null } else { $in * 1_000_000_000 | into datetime } +} + +def date-to-datetime []: any -> any { + if ($in | is-empty) { null } else { $"($in)T00:00:00+00:00" | into datetime } +} + +# List the indicators published for a currency +# +# Each row is one indicator slug that can be passed to `fxmacrodata history`. +@example "USD indicators that have recent data" { fxmacrodata catalogue usd | where has_recent_data } +export def catalogue [ + currency: string@"nu-complete fxmacrodata currencies" = "usd" # three-letter currency code + --raw # return the unmodified JSON response +]: nothing -> any { + let body = api-get $"/data_catalogue/($currency | str lowercase)" + if $raw { return $body } + + $body + | transpose indicator meta + | each {|row| + let cov = $row.meta.coverage? | default {} + { + indicator: $row.indicator + name: $row.meta.name? + unit: $row.meta.unit? + frequency: $row.meta.frequency? + source: $row.meta.source? + earliest: ($cov.earliest_available_date? | date-to-datetime) + latest: ($cov.latest_available_date? | date-to-datetime) + latest_release: ($cov.latest_release_date? | date-to-datetime) + has_recent_data: $cov.has_recent_data? + requires_api_key: $cov.requires_api_key? + } + } +} + +# Get release history for one indicator, most recent first +# +# `date` is the reference period and `released` is when the figure was +# published. Without an API key, USD history covers roughly the last 90 days +# and new releases appear after a short delay; a notice is printed to stderr. +@example "Last 12 US CPI releases" { fxmacrodata history usd inflation --limit 12 } +@example "US payrolls since the start of 2025 (older history needs an API key)" { fxmacrodata history usd non_farm_payrolls --start 2025-01-01 } +export def history [ + currency: string@"nu-complete fxmacrodata currencies" # three-letter currency code + indicator: string # indicator slug, see `fxmacrodata catalogue` + --start: string # first period date, YYYY-MM-DD + --end: string # last period date, YYYY-MM-DD + --limit: int # maximum number of rows (1-100) + --raw # return the unmodified JSON response +]: nothing -> any { + let body = api-get $"/announcements/($currency | str lowercase)/($indicator)" { + start_date: $start + end_date: $end + limit: $limit + } + report-free-tier $body + if $raw { return $body } + + $body.data + | each {|row| + { + date: ($row.date | date-to-datetime) + value: $row.val + previous: $row.previous_value? + released: ($row.announcement_datetime? | epoch-to-datetime) + source: $row.source? + } + } +} + +# Get upcoming scheduled releases for a currency +@example "Upcoming US releases" { fxmacrodata calendar usd } +@example "Next US CPI releases" { fxmacrodata calendar usd --indicator inflation } +export def calendar [ + currency: string@"nu-complete fxmacrodata currencies" = "usd" # three-letter currency code + --indicator: string # only show releases for this indicator slug + --raw # return the unmodified JSON response +]: nothing -> any { + let body = api-get $"/calendar/($currency | str lowercase)" {indicator: $indicator} + if $raw { return $body } + + $body.data + | each {|row| + { + release_time: ($row.announcement_datetime | epoch-to-datetime) + indicator: $row.release + name: $row.name? + importance: $row.event_importance? + confirmed: $row.release_date_confirmed? + } + } +} + +# Get daily FX spot rates for a currency pair, most recent first +# +# Requires an API key in `$env.FXMACRODATA_API_KEY`. +@example "Last 30 EUR/USD fixes" { fxmacrodata forex eur usd --limit 30 } +export def forex [ + base: string@"nu-complete fxmacrodata currencies" # base currency code + quote: string@"nu-complete fxmacrodata currencies" # quote currency code + --start: string # first date, YYYY-MM-DD + --end: string # last date, YYYY-MM-DD + --limit: int # maximum number of rows (1-100) + --raw # return the unmodified JSON response +]: nothing -> any { + if ($env.FXMACRODATA_API_KEY? | is-empty) { + error make --unspanned { + msg: "fxmacrodata forex needs an API key: set $env.FXMACRODATA_API_KEY" + } + } + let body = api-get $"/forex/($base | str lowercase)/($quote | str lowercase)" { + start_date: $start + end_date: $end + limit: $limit + } + if $raw { return $body } + + $body.data + | each {|row| + { + date: ($row.date | date-to-datetime) + rate: $row.val + } + } +} From 101956ee54149ab0f9494becca3e543b3f603db5 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Mon, 5 Oct 2026 14:00:26 +1100 Subject: [PATCH 2/3] fxmacrodata: rename history to announcements, quieter free-tier notices --- modules/README.md | 3 ++- modules/fxmacrodata/README.md | 7 +++---- modules/fxmacrodata/mod.nu | 31 ++++++++++++++++++++----------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/modules/README.md b/modules/README.md index 4e3c2cd02..5881eb789 100644 --- a/modules/README.md +++ b/modules/README.md @@ -113,7 +113,8 @@ Examples of input/output formatters: ## [fxmacrodata](./fxmacrodata/) -Economic indicator releases, release calendars and FX rates from the FXMacroData API as Nushell tables. +Economic indicator releases, release calendars and FX rates from the FXMacroData API as Nushell tables +(`fxmacrodata catalogue`, `fxmacrodata announcements`, `fxmacrodata calendar`, `fxmacrodata forex`). see [README](./fxmacrodata/) ## github diff --git a/modules/fxmacrodata/README.md b/modules/fxmacrodata/README.md index 8a2dc2065..df5fde64f 100644 --- a/modules/fxmacrodata/README.md +++ b/modules/fxmacrodata/README.md @@ -9,7 +9,7 @@ use modules/fxmacrodata | Command | Description | |---------|-------------| | `fxmacrodata catalogue [currency]` | indicators available for a currency (default `usd`) | -| `fxmacrodata history ` | release history for one indicator, most recent first. `--start`, `--end` (YYYY-MM-DD), `--limit` | +| `fxmacrodata announcements ` | release history for one indicator, most recent first. `--start`, `--end` (YYYY-MM-DD), `--limit` | | `fxmacrodata calendar [currency]` | upcoming scheduled releases. `--indicator` to filter | | `fxmacrodata forex ` | daily FX spot rates. `--start`, `--end`, `--limit` (needs an API key) | @@ -17,7 +17,7 @@ Every command accepts `--raw` to return the unmodified JSON response instead of ## API key -USD indicators and the USD calendar work without a key. Keyless history covers roughly the last 90 days and new releases show up after a short delay; when that applies a notice is printed to stderr. Other currencies and `forex` need a key: +USD indicators and the USD calendar work without a key. Keyless history covers roughly the last 90 days and new releases show up after a short delay; when either of those changes what you get back, a notice is printed to stderr. Other currencies and `forex` need a key: ```nushell $env.FXMACRODATA_API_KEY = "..." @@ -28,9 +28,8 @@ The key is only ever sent in the `X-API-Key` request header. ## Examples ```nushell -> fxmacrodata history usd inflation --limit 5 | update date { format date "%Y-%m-%d" } +> fxmacrodata announcements usd inflation --limit 5 | update date { format date "%Y-%m-%d" } fxmacrodata: Anonymous access returns the most recent 90 days. Supply an API key for the full history. -fxmacrodata: Free access is delayed by 15 minutes. Releases published in the last 15 minutes are withheld. An Individual or Business API key returns them in real time. ╭───┬────────────┬───────┬──────────┬──────────────┬────────╮ │ # │ date │ value │ previous │ released │ source │ ├───┼────────────┼───────┼──────────┼──────────────┼────────┤ diff --git a/modules/fxmacrodata/mod.nu b/modules/fxmacrodata/mod.nu index 5a832a0ba..f1a304304 100644 --- a/modules/fxmacrodata/mod.nu +++ b/modules/fxmacrodata/mod.nu @@ -47,17 +47,25 @@ def api-get [path: string, query: record = {}]: nothing -> any { $response.body } -# Print free-tier notices (history window, release delay) to stderr +# Print free-tier notices to stderr, but only when they affected the result: +# the history window when it cut rows from this page, the release delay when +# it withheld a release def report-free-tier [body: record] { let window = $body.freemium_window? | default {} - if ($window.applied? | default false) { + let page = $body.pagination? | default {} + let returned = $page.returned_count? | default ($body.data? | default [] | length) + let trimmed = ( + ($window.applied? | default false) + and not ($page.has_more? | default false) + and $returned < ($page.limit? | default 0) + ) + if $trimmed { print --stderr $"fxmacrodata: ($window.message? | default 'free tier history is limited')" } let delay = $body.freemium_delay? | default {} - if ($delay.applied? | default false) { - let withheld = $delay.withheld_count? | default 0 - let suffix = if $withheld > 0 { $" \(($withheld) release\(s\) withheld\)" } else { "" } - print --stderr $"fxmacrodata: ($delay.message? | default 'free tier data is delayed')($suffix)" + let withheld = $delay.withheld_count? | default 0 + if ($delay.applied? | default false) and $withheld > 0 { + print --stderr $"fxmacrodata: ($delay.message? | default 'free tier data is delayed') \(($withheld) release\(s\) withheld\)" } } @@ -71,7 +79,7 @@ def date-to-datetime []: any -> any { # List the indicators published for a currency # -# Each row is one indicator slug that can be passed to `fxmacrodata history`. +# Each row is one indicator slug that can be passed to `fxmacrodata announcements`. @example "USD indicators that have recent data" { fxmacrodata catalogue usd | where has_recent_data } export def catalogue [ currency: string@"nu-complete fxmacrodata currencies" = "usd" # three-letter currency code @@ -103,10 +111,11 @@ export def catalogue [ # # `date` is the reference period and `released` is when the figure was # published. Without an API key, USD history covers roughly the last 90 days -# and new releases appear after a short delay; a notice is printed to stderr. -@example "Last 12 US CPI releases" { fxmacrodata history usd inflation --limit 12 } -@example "US payrolls since the start of 2025 (older history needs an API key)" { fxmacrodata history usd non_farm_payrolls --start 2025-01-01 } -export def history [ +# and new releases appear after a short delay; a notice is printed to stderr +# when either of those changes the result. +@example "Last 12 US CPI releases" { fxmacrodata announcements usd inflation --limit 12 } +@example "US payrolls since the start of 2025 (older history needs an API key)" { fxmacrodata announcements usd non_farm_payrolls --start 2025-01-01 } +export def announcements [ currency: string@"nu-complete fxmacrodata currencies" # three-letter currency code indicator: string # indicator slug, see `fxmacrodata catalogue` --start: string # first period date, YYYY-MM-DD From 9ad30b1a5d94575e79df92cca894d2d29d62bff7 Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Mon, 5 Oct 2026 20:56:45 +1100 Subject: [PATCH 3/3] fxmacrodata: do not follow redirects, reject unexpected responses --- modules/fxmacrodata/mod.nu | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/fxmacrodata/mod.nu b/modules/fxmacrodata/mod.nu index f1a304304..4a0d827a3 100644 --- a/modules/fxmacrodata/mod.nu +++ b/modules/fxmacrodata/mod.nu @@ -17,7 +17,7 @@ def "nu-complete fxmacrodata currencies" [] { } def api-headers []: nothing -> record { - let key = $env.FXMACRODATA_API_KEY? | default "" + let key = $env.FXMACRODATA_API_KEY? | default "" | str trim if ($key | is-empty) { {} } else { {X-API-Key: $key} } } @@ -33,7 +33,8 @@ def api-get [path: string, query: record = {}]: nothing -> any { $"($BASE_URL)($path)?($params | transpose -r -d | url build-query)" } - let response = http get --full --allow-errors --headers (api-headers) $url + # never follow redirects, so the key is not re-sent to another host + let response = http get --full --allow-errors --redirect-mode error --headers (api-headers) $url if $response.status >= 400 { let detail = if ($response.body | describe) =~ "^record" { $response.body.detail? | default ($response.body | to json -r) @@ -44,7 +45,13 @@ def api-get [path: string, query: record = {}]: nothing -> any { msg: $"FXMacroData request failed with HTTP ($response.status): ($detail)" } } - $response.body + let body = $response.body + if ($body | describe) !~ "^record" or ($body.detail? != null and $body.data? == null) { + error make --unspanned { + msg: "FXMacroData returned an unexpected response" + } + } + $body } # Print free-tier notices to stderr, but only when they affected the result: