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.
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:
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:
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_LIMITrather thanAUTH, 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
The two customer-facing routes are public — this is market data, not account data — and neither ever returns a hidden row.
Next
- Market data providers — which vendor serves what, and how to test a key before committing it
- API reference — the customer calendar and news routes and their query parameters
- Settings and reference — every permission key and every scheduled job in one table