--- name: fx-rates-scraper description: Read European Central Bank daily reference rates as flat rows via the Apify Actor arman-bd/fx-rates-scraper. Returns one record per currency per quoted date with the fixing date, base and target codes, the rate, a pair label, the percent change from the previous quoted date and the high and low across the requested range. Use when a task needs an accounting or audit-grade conversion rate for a specific date, a multi-year daily series for backtesting, or a scheduled refresh of billing currencies. Not for intraday, tick or executable trading quotes, and not for currencies the ECB does not publish. --- # FX Rates Scraper Apify Actor `arman-bd/fx-rates-scraper`. Give it a base currency and, optionally, a set of targets and a date or date range, get one dataset record per currency per quoted date. No credentials and no proxy configuration are involved. ## When to use it - Restating figures at a specific fixing date, where an auditor expects the ECB rate. - A multi-year daily series for one pair, for backtesting or exposure analysis. - A scheduled refresh of the rates behind multi-currency invoicing or price lists. - A one-off snapshot of every published currency against one base. - Range statistics: `periodHigh`, `periodLow` and `changePercent` arrive precomputed, so no post-processing is needed for a simple volatility view. ## When not to use it - Intraday, tick or minute data. One reference rate per currency per working day is all that exists in this source. - Executable or dealable quotes. These are statistical reference rates and carry no spread; do not price a trade off them. - Crypto, commodities, metals or any currency outside the ECB's published list. - Dates before 1999-01-04, or any future date. Both are out of range. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/fx-rates-scraper').call({ baseCurrency: 'EUR', targetCurrencies: ['USD', 'GBP'], fromDate: '2026-01-01', toDate: '2026-06-30', }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~fx-rates-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"baseCurrency":"EUR","targetCurrencies":["USD","GBP","JPY","CHF"]}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/fx-rates-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `baseCurrency` | string | no | `EUR` | Three-letter ISO code, uppercased for you. EUR is the ECB's own base; any other base is derived from the EUR cross rates. A code that is not three letters, or is not published, throws before anything is saved. | | `targetCurrencies` | string[] | no | `[]` | Codes to quote against the base. Empty means every published currency, which is about 30 rows per date. The base is removed from this list if present. A code that is not three letters throws; a well-formed code the ECB does not publish is skipped and recorded. | | `date` | string | no | `""` | One historical fixing, `YYYY-MM-DD`. Empty means the latest publication. Ignored entirely when a range is set. | | `fromDate` | string | no | `""` | Series start, `YYYY-MM-DD`. Earliest available is 1999-01-04. | | `toDate` | string | no | `""` | Series end, `YYYY-MM-DD`. Empty means through the latest publication. | | `maxResults` | integer | no | `0` | Row cap across the whole run. `0` means no limit. | The three date fields select a mode, with the range taking precedence: | You set | Mode | What you get | |---|---|---| | nothing | latest | The most recent published fixing, one row per currency. | | `date` | single date | That day's fixing, one row per currency. | | `fromDate` or `toDate` | time series | One row per currency per quoted day in the range. | **Row count is the product of currencies and quoted dates, and that is the whole sizing problem.** Roughly 255 quoted days a year, times the number of targets. Two years of one pair is about 510 rows; two years of every published currency is about 15,000. Name your targets explicitly unless you genuinely want all of them, and prefer narrowing `targetCurrencies` over capping with `maxResults`, because the cap truncates by series rather than trimming evenly. ## Output One record per currency per quoted date. | Field | Type | Notes | |---|---|---| | `date` | string | The fixing date the ECB actually quoted, `YYYY-MM-DD`. This can be earlier than the date you asked for. | | `baseCurrency` | string | The normalised base code, uppercase. | | `targetCurrency` | string | The quoted currency, uppercase. | | `rate` | number | Units of `targetCurrency` per one `baseCurrency`. | | `series` | string | Pair label, `BASE/TARGET`, for example `EUR/USD`. Convenient group key. | | `changePercent` | number \| null | Percent move from the previous quoted date within this run's range, rounded to four decimals. `null` on the first observation of a series. | | `periodHigh` | number | Highest `rate` for this pair across the requested range. Repeated on every row of the pair. | | `periodLow` | number | Lowest `rate` for this pair across the requested range. Repeated on every row of the pair. | | `scrapedAt` | string | Run timestamp, ISO 8601 UTC. Not the fixing time. | A real record from a time-series run: ```json { "date": "2026-08-03", "baseCurrency": "EUR", "targetCurrency": "USD", "rate": 1.1535, "series": "EUR/USD", "changePercent": 0.4354, "periodHigh": 1.1554, "periodLow": 1.1367, "scrapedAt": "2026-08-06T11:45:44.114Z" } ``` That `changePercent` is the move from `2026-07-31`, the previous *quoted* date, not from `2026-08-02`, because the ECB does not quote at weekends. ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** It names the mode that was actually used and the span that was actually returned. ```json { "mode": "timeseries", "baseCurrency": "EUR", "currenciesReturned": 2, "datesReturned": 8, "firstDate": "2026-07-27", "lastDate": "2026-08-05", "seriesSaved": 16, "failures": [], "perSeries": [ { "series": "EUR/GBP", "dates": 8, "saved": 8, "periodHigh": 0.8572, "periodLow": 0.85524 }, { "series": "EUR/USD", "dates": 8, "saved": 8, "periodHigh": 1.1554, "periodLow": 1.1367 } ], "filters": { "baseCurrency": "EUR", "targetCurrencies": ["USD", "GBP"], "date": "", "fromDate": "2026-07-27", "toDate": "2026-08-05", "maxResults": 0 }, "finishedAt": "2026-08-06T11:45:44.114Z" } ``` Check three things. `mode` should be the mode you meant: seeing `timeseries` when you set only `date` means a stray `fromDate` or `toDate` came through. Every entry in `perSeries` should have `saved` equal to `dates`; where it is smaller, or a requested pair has no entry at all, `maxResults` truncated the run. `failures` lists codes that were requested but not returned, each with a `currency` and an `error`. Also compare `firstDate` and `lastDate` against the range you asked for: they are the quoted dates the source had, not your bounds. ## Behaviour to plan around - **You often get a different date than you asked for.** The ECB quotes on working days only, published once each business day in the late afternoon Central European time. A weekend, a euro-area holiday, or a run made before that day's publication all return the previous working day's fixing under that earlier date. Always read `date`, never assume it matches your input. - **Time series have holes, and they are not filled.** Weekends and holidays are simply absent. There is no carry-forward and no gap-filling, so consecutive rows can be three days apart, and `changePercent` spans that gap. - **Setting only `toDate` pulls the whole history.** The range mode fills a missing start with 1999-01-04. If you want a short window, always set `fromDate` too. - **`maxResults` truncates by series, not evenly.** Currencies are written in alphabetical order and the run stops the moment the cap is hit, so a low cap gives you complete early series and nothing at all for the later ones. Narrow `targetCurrencies` instead when you want a balanced sample. - **`changePercent` is `null` on the first row of every series** because there is no earlier observation inside the requested range. Widen `fromDate` by a few days if you need a change figure on your first date of interest. - **`periodHigh` and `periodLow` are range statistics, not all-time.** They are computed over exactly the dates this run returned and repeated on every row, so you can filter to one row and still see the range. - **The base is never quoted against itself.** `EUR` in `targetCurrencies` with `EUR` as the base is dropped, since the row would always be 1. - **Unpublished currency codes are caught before the data request.** A well-formed code the ECB does not publish is logged, recorded in `failures` and skipped rather than silently vanishing. A malformed code, an out-of-order range, or a base that is not published all throw immediately. - **Some failures still write a summary.** When the rate request itself fails, a reduced `RUN_SUMMARY` with `seriesSaved` of 0 is written before the run throws, so the error is readable from the key-value store as well as the log. - **Transient failures are retried** three times with linear backoff. An out-of-range date is not retried, because it will never succeed. ## Recipes **Month-end restatement.** One fixing, the currencies you report in. ```json { "baseCurrency": "EUR", "targetCurrencies": ["USD", "GBP", "CHF", "JPY"], "date": "2025-12-31" } ``` Read `date` on each row before using the rate: if 31 December fell at a weekend, you have the last working day's fixing, which is normally the correct accounting answer, but you should record which date you used. **Multi-year backtest for one pair.** Narrow targets, wide range. ```json { "baseCurrency": "EUR", "targetCurrencies": ["USD"], "fromDate": "2020-01-01", "toDate": "2026-01-01" } ``` Sort by `date`, then use `changePercent` directly as the daily return series. About 1,500 rows. **Daily billing refresh.** Latest mode, scheduled. ```json { "baseCurrency": "USD", "targetCurrencies": ["EUR", "GBP", "SEK", "PLN", "CZK"] } ``` Run on weekday afternoons Central European time. Upsert on `series` and keep `date` next to the rate so a stale weekend value is visible rather than silent. **Full snapshot of every published currency.** No targets, no dates. ```json { "baseCurrency": "EUR" } ``` About 30 rows, all sharing one `date`. Useful as a lookup table, and as a way to discover exactly which codes are available before you name them in a larger run.