The economic calendar and news feeds

Two admin screens with full CRUD behind the terminal's calendar and news widgets — PROVIDER versus MANUAL rows, why editing a provider row does not stick, the feed-status banner and the permission key that gates it.

7 min readUpdated 6 August 2026economic-calendar, news, finnhub, feeds, permissions

Two widgets in the customer terminal are backed by two full admin screens:

Screen Path Table Customer route
Economic Calendar /admin/forex-trading/economic-event fx_economic_event GET /api/forex-trading/calendar
Market News /admin/forex-trading/news fx_market_news GET /api/forex-trading/news

Both are under Admin → Forex Trading → Market Data, and both offer create, edit and delete — unlike the instrument catalog next to them in the same menu.

Neither feed is required. A desk with no calendar provider at all still runs a usable calendar, by hand. That is the design decision the rest of this page follows from.

Two kinds of row in one table

Every row on both tables carries a source of PROVIDER or MANUAL.

PROVIDER MANUAL
Written by The 15-minute syncFxCalendarAndNews job You, on these screens
externalId <provider>:<vendor id>, or a stable hash when the vendor publishes no id. UNIQUE NULL
provider The vendor's name NULL
Touched by the sync Yes — inserted, refreshed, pruned Never
Pruned automatically Calendar after 90 days, news after 30 Never — they are yours to remove

Anything you create on these screens is MANUAL with a null externalId, and that null is exactly what keeps the sync away from it. Nothing in the sync path ever reads, updates or deletes a MANUAL row.

So an operator with no paid calendar feed can enter Non-Farm Payrolls, the ECB rate decision and CPI by hand every month and the terminal widget works identically. It is the supported fallback, not a hack.

Editing a provider row is not durable

Macro releases move: an actual figure arrives after the release, vendors revise a forecast, and a scheduled time shifts. The calendar sync refreshes any PROVIDER row whose actual, forecast, previousValue, impact or eventTime differs from what the vendor now publishes — so a correction you type into those fields is reverted the next time the vendor's figure differs from yours, within fifteen minutes.

News behaves differently, and the difference is worth knowing: a story is immutable once published, so the news sync leaves existing rows untouched and an edit to a provider news row does stick.

What is not durable on either table is a delete. The sync re-inserts by externalId, so a provider row you delete comes straight back on the next run that still sees it in the vendor's window.

The durable operator control over a bad provider row is its status:

Visible to clients. The sync never writes this column, so a hidden row stays hidden

Set it false and the row stays in the table, keeps being refreshed, and is never returned to a customer — the public calendar and news routes filter hidden rows out. That is the right answer for a duplicated release, a garbage headline or a story you do not want on your terminal.

Both screens' create/edit forms end with a Visibility group holding exactly that switch.

What you can author

A calendar row

Required: title (≤191 characters), eventTime (ISO 8601, UTC) and impact — one of LOW, MEDIUM, HIGH.

Optional: country and currency (≤8 characters, upper-cased on write), and the four figure fields actual, forecast, previousValue and unit, each capped at 32 characters.

The figures are strings, not numbers, deliberately. Vendors publish 3.2%, 1.5M and -0.1, and normalising them would throw away the unit a trader needs to read. Type them exactly as you want them displayed.

currency is what drives the terminal's per-symbol filter, so it is the field to fill in. Provider rows get theirs from a country-to-currency map covering the unambiguous majors; an unmapped country simply leaves currency null, and a row with no currency is always included in the unfiltered calendar.

A news row

Required: headline, which is converted to plain text on write and capped at 500 characters.

Optional: summary (plain text, capped at 5,000), publishedAt (ISO 8601; defaults to now on create), url and imageUrl, category (≤64) and relatedSymbols — a comma-separated list or JSON array, upper-cased and capped at 25 entries.

Links render as anchors in the terminal, so any other scheme is refused with "must use http or https". A javascript: URL stored here would be a stored XSS the moment the feed rendered it.

relatedSymbols is not decoration either. The customer news route's symbol filter matches a story that names either leg or the closed-up pair (EURUSD) in its headline or summary — or carries the symbol as an operator tag. Tagging is how a desk note reaches a scoped feed without naming the pair in its prose.

The feed-status banner

Both screens render a banner above the table, fed by one endpoint:

Why the calendar and news tables look the way they do

An empty table has three completely different causes and only one of them is something you can act on, so the banner distinguishes them.

Outcome What it means What to do
OK The provider replied. The banner shows which one, how long ago and how many rows were written — including zero rows, which is a healthy answer for a quiet week Nothing
NOT_IMPLEMENTED No configured provider implements this feed at all Configure a provider that does — see below
PLAN_LIMIT The provider implements it and your key's plan refuses it. For Finnhub's calendar this is the normal free-tier answer Upgrade the plan, or author rows by hand
ERROR The last fetch failed — rate limit, cool-off, network Wait; the sync retries on its own schedule. Manual rows are unaffected

The same payload also tells you calendarImplementers and newsImplementers (which adapters could serve each feed) and keyedProviders (which of your provider rows actually hold an API key in the environment). The banner turns those into the concrete instruction — set APP_FINNHUB_API_KEY and restart the backend.

The sync's outcome is persisted in a single settings row, fxTradingCalendarSyncState, as JSON.

Settings are stored as text. The endpoint parses that row inside a try/catch and falls back to null rather than throwing, because it exists precisely to explain a broken state — an endpoint that 500s when the state is corrupt would fail at the one moment it is needed.

Only Finnhub serves either feed

CALENDAR_IMPLEMENTERS and NEWS_IMPLEMENTERS are both ["finnhub"]. Twelve Data, TraderMade and Polygon implement neither, and asking them returns NOT_IMPLEMENTED rather than an error.

On Finnhub's own tiers the two feeds differ:

  • News is free on the same key.
  • The economic calendar is a paid endpoint. A free key gets a 403 that the adapter deliberately re-classifies as PLAN_LIMIT rather than AUTH, so a perfectly good key is not banned over a paywalled feature.

This is the part operators get wrong. Quoting is strictly single-active — one fx_provider row carries status = true — but the calendar and news sync enumerates every provider row that holds an API key, active or not.

So the recommended production setup (Twelve Data quoting, because it covers forex, stocks, commodities and indices over one API) still gets a free news feed by adding APP_FINNHUB_API_KEY to the environment and restarting the backend. You do not have to switch quote providers, and you do not have to activate the Finnhub row.

If two providers could serve the same feed, the first one wins and the results are not merged — rows are keyed <provider>:<id>, so merging would show the same release twice.

The permission trap

GET /api/admin/forex-trading/calendar/feed-status checks view.forex_trading.calendar — the key that governs trading-hours session calendars at /admin/forex-trading/calendar, a different screen about a different thing.

The two screens that render the banner are gated in the frontend permission manifest on view.forex_trading.instrument, and their data routes check the finer view.forex_trading.economic_event and view.forex_trading.news.

A fourth key is checked in the browser: both pages hand the data table access.forex_trading.economic_event and access.forex_trading.news as its access permission, and the table refuses to fetch at all unless the signed-in role holds it.

Four different keys, one screen. A role granted only the economic-event and news keys can open neither page. A role granted only the instrument key can open both and gets an Access denied overlay where the table should be — it holds neither the access. key the table checks nor the view. key the route behind it checks — and no banner at all, because the banner fetches silently and renders nothing when it is refused, so a missing banner looks identical to a healthy install that has not synced yet.

Roles that existed when the permission-key rename ran were spared this: that migration fanned view.forex_trading.instrument out onto the calendar, economic-event, news and group keys in one pass. Roles you create afterwards get exactly what you tick.

Grant a calendar/news editor all of these together:

Key Grants
view.forex_trading.instrument Opening either screen
view.forex_trading.calendar The feed-status banner
access.forex_trading.economic_event The calendar table's own client-side gate — without it the table is Access denied
view.forex_trading.economic_event Reading the calendar table
create / edit / delete.forex_trading.economic_event Authoring calendar rows
access.forex_trading.news The news table's own client-side gate
view.forex_trading.news Reading the news table
create / edit / delete.forex_trading.news Authoring news rows

The screen gates are historical. view.forex_trading.instrument is marked keep: true in the permission-key rename migration precisely so it can fan existing grants onto the finer data keys. Narrowing the screen gates to match the routes changes who can open the pages, so it has not been done.

The access.forex_trading.* family is very much alive, so do not read the two access. rows above as a mistake. access.forex_trading gates the addon landing and the settings screen, access.forex_trading.execution the two execution screens, access.forex_trading.instrument, .order and .position the symbol-group, order and position screens, and the platform-wide operations summary checks access.forex_trading.withdraw. What is specific to access.forex_trading.economic_event and access.forex_trading.news is that no migration carries an existing grant onto them and no backend route checks them: they are seeded as grantable rows that no role holds until you tick them by hand. That is survivable on the table, which says Access denied out loud, and would not have been on the middleware screen gate, where the same miss redirects with ?auth=false and reads as a broken menu.

The sync job

Admin → System → Cron, Fx Calendar & News Sync (syncFxCalendarAndNews), period 15 minutes, category forex_trading.

Property Value
Calendar window Yesterday through +14 days
News fetched per run 60 stories
Calendar retention PROVIDER rows older than 90 days are pruned
News retention PROVIDER rows older than 30 days are pruned
Failure behaviour A quiet no-op. Logged, recorded in the sync state, never an operator alert

That last line is deliberate: an unimplemented capability or a paywalled endpoint is a configuration state, not an incident. It is also why the banner exists — the cron writes a log line nobody reads, and the banner is what puts the same information in front of the person staring at an empty table.

A 429 or a rejected key is a property of the account, and the same quota funds the live tick feed and the chart back-fill. A failed calendar or news fetch therefore arms the shared provider cool-off, and you may see quoting degrade alongside it. That is correct behaviour, not a second fault — but if your feeds report ERROR repeatedly, treat it as a provider-plan problem and read Market data providers.

Endpoints

Lists calendar rows, PROVIDER and MANUAL
Creates a MANUAL calendar row
Updates a calendar row
Deletes a calendar row — a PROVIDER row returns on the next sync
Lists news rows, PROVIDER and MANUAL
Creates a MANUAL news row
Updates a news row
Deletes a news row — a PROVIDER row returns on the next sync

The two customer-facing routes are public — this is market data, not account data — and neither ever returns a hidden row.

Next