News
Scored headlines for Loaf’s listed real assets: property, data centres, refineries, fabs, grids, hotels. No API key required.
| News service | Loaf Backend | |
|---|---|---|
| REST | https://news.loafmarkets.com/v1/headlines | https://api.loafmarkets.com |
| WebSocket | wss://news.loafmarkets.com/v1/ws/headlines | wss://api.loafmarkets.com/ws |
| Auth | None | Bearer API key on private routes |
| Python SDK | Not wrapped by LoafClient | loaf-python-api-bot-template |
Use requests (or axios) plus a WebSocket client. See Building a trading bot for the seed-then-listen loop.
Headlines live about 7 days. After that, GET /v1/headlines/:id is 404 and a WebSocket last_seen_id replay returns cursor_expired. Re-seed from REST.
How the frontend uses this
Hub, trade, and offerings seed once, then keep a single socket open. Same pattern a bot should use.
GET /v1/headlinesto fill the panel.- Open
wss://news.loafmarkets.com/v1/ws/headlines, sendsubscribe, prepend eacharticleframe.
The panel (PropertyNewsItem) reads these fields off each row:
| Panel field | Feed field |
|---|---|
id, url, breaking | same names |
title | title_rewritten |
publishedAt / date | published_at |
source | source_name |
summary | excerpt (fallback summary) |
sentimentScore | sentiment_score (−1…1, always the story’s own reading; it does not decay with age) |
impactScore | impact_score (0–10) |
impactRationale | impact_rationale |
tickers | tickers — external symbols (NVDA, TSM), not Loaf tickers |
type | feed_type — "property" or "market" |
assets | assets — resolved platform badges, only when grading is on |
tickers cannot be matched against the platform roster. assets is { ticker, tokenName, name, imageUrl } and is the only field that identifies a listed Loaf asset.
The panel has two tabs. feed_type is the primary tab (higher of property_score / market_score; category breaks a tie). feed_types is the full list: a story that scores hard on both axes appears in both tabs. GET /v1/headlines?feed_type= filters on that array, so a dual story is returned for either tab, once per response.
Ask once vs listen
Same rule as the rest of the API:
- Ask once —
GET /v1/headlines(and/v1/headlines/:id). Photo of the current page. - Listen — WebSocket
articleframes. New and replayed rows only; not a full snapshot.
Do not poll REST in a loop. Seed, then subscribe. Pass last_seen_id so a dropped socket can catch up.
Article object
Every headline row, REST or WebSocket, is this shape (derived fields included):
type FeedType = 'property' | 'market';
type Category =
| 'market_news'
| 'property_update'
| 'policy'
| 'economic'
| 'geopolitical'
| 'sale_transaction'
| 'energy'
| 'infrastructure'
| 'other';
interface Article {
id: string;
source_id: string;
source_name: string;
url: string;
title_original: string;
title_rewritten: string;
excerpt?: string;
summary?: string;
published_at: string; // ISO-8601
suburbs: { name: string; region?: 'central' | 'eastern' | 'north' | 'western' }[];
foreign_locations: string[];
/** External market symbols the enricher attached. Not Loaf tickers. */
tickers: string[];
category: Category;
sentiment: 'positive' | 'negative' | 'neutral';
/** Net direction (−1…1): asset leg mixed with market leg. */
sentiment_score?: number;
/** Asset-specific direction, before the market is mixed in. */
property_sentiment: number;
/** Direction for the asset’s market / class. */
market_sentiment: number;
impact_score: number; // 0–10, derived from the two axes
property_score: number; // 0–10
market_score: number; // 0–10
asset_types: string[];
impact_rationale: string;
tags: string[];
breaking: boolean;
feed_type: FeedType;
feed_types: FeedType[];
last_updated_at?: string;
update_count: number;
also_reported_by: string[];
/** Present only when asset grading is on and this story graded to listed assets. */
assets?: { ticker: string; tokenName: string; name: string; imageUrl: string | null }[];
}property_score is how much the story reprices a tagged asset; market_score is how much it moves the class. A leaky roof is high property / low market. An RBA cut is the reverse. sentiment_score can disagree with property_sentiment on one asset: good asset news into a sold-off market is a real case, and a single net number cannot say it.
Duplicate coverage of the same story collapses onto the first entry (update_count, also_reported_by, last_updated_at). published_at on that entry is when the story broke.
Endpoints
| Method | Path | Description |
|---|---|---|
GET | /v1/headlines | Paginated feed |
GET | /v1/headlines/:id | Single article |
GET | /v1/assets | Roster as this service sees it (grading on) |
GET | /v1/assets/:token/headlines | That asset’s news (grading on) |
GET | /v1/articles/:id/assets | Which assets one story graded to (grading on) |
GET | /v1/sentiment | Impact-weighted sentiment for a scope + window |
GET | /v1/prices | Live instrument quotes (price feed on) |
GET | /health | { status, redis, … } — 503 if Redis is down |
Error body:
{ "error": { "code": "INVALID_QUERY", "message": "...", "details": [] } }GET /v1/assets, /v1/assets/:token/headlines, /v1/articles/:id/assets, and /v1/prices return 503 with ASSET_GRADING_DISABLED or PRICE_FEED_DISABLED when that add-on is off. That is not an empty feed. Do not treat 503 as “no news.”
List routes send Cache-Control: public, max-age=30. Price routes send no-store.
List headlines
GET /v1/headlines
Newest-first page, ranked by decayed impact_score then presented reverse-chronologically. A publisher cap keeps one outlet from filling the page.
Request
import axios from 'axios';
const response = await axios.get('https://news.loafmarkets.com/v1/headlines', {
params: { limit: 20, min_impact: 5, feed_type: 'property' },
});
const { headlines, next_cursor } = response.data;Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
feed_type | property | market | No | Tab filter. Matches feed_types, so a dual story is returned for either value. |
category | Category | No | One of the nine category values. |
min_impact | int 0–10 | No | Drop rows below this impact_score. |
since | ISO-8601 | No | Only articles published at or after this timestamp. |
suburb | string | No | Single suburb name. |
suburbs | CSV string | No | Several suburbs, comma-separated. |
region | central | eastern | north | western | No | Sydney region. |
limit | int 1–200 | No | Page size. Default 50. |
cursor | string | No | Opaque cursor from the previous next_cursor. |
Response
interface HeadlinesResponse {
headlines: Article[];
next_cursor: string | null;
server_time: string;
}Walk pages with next_cursor until it is null.
Example row:
{
"id": "a-8f3c21",
"title_rewritten": "China halts gallium exports",
"source_name": "DigiTimes",
"url": "https://example.com/gallium",
"published_at": "2026-08-25T04:12:00.000Z",
"excerpt": "Export controls tighten on a key semiconductor input.",
"category": "geopolitical",
"sentiment": "negative",
"sentiment_score": -0.62,
"property_sentiment": -0.7,
"market_sentiment": -0.4,
"impact_score": 8,
"property_score": 4,
"market_score": 8,
"feed_type": "market",
"feed_types": ["market"],
"tickers": ["NVDA"],
"breaking": true,
"assets": [
{ "ticker": "YONG", "tokenName": "yongin", "name": "Yongin", "imageUrl": "https://…/banner.webp" }
]
}assets is omitted when grading is off, or when the story graded to nothing on the roster.
Get one article
GET /v1/headlines/:id
Same Article as a list row, including assets when grading is on. 404 NOT_FOUND if the id expired or never existed.
const { data } = await axios.get('https://news.loafmarkets.com/v1/headlines/a-8f3c21');Per-asset headlines
Only when ASSET_GRADING_ENABLED is on; otherwise 503 ASSET_GRADING_DISABLED.
Each article is scored per listed asset. The same story can be negative for a fab and noise for a hotel. The headline on an asset page is a property of (article × asset), not of the article alone.
GET /v1/assets — roster as this service sees it (token, ticker, name, type, country, profiled, synced_at). First thing to check if an asset feed is empty.
GET /v1/assets/:token/headlines — that asset’s news. :token accepts the Loaf tokenName or the ticker (e.g. yongin or YONG).
Request
const { data } = await axios.get(
'https://news.loafmarkets.com/v1/assets/opera/headlines',
{ params: { min_property_score: 7, limit: 20 } },
);Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
min_property_score | int 0–10 | No | Floor on this asset’s property_score. Default on the service is 6 for what appears on an asset page. |
since | ISO-8601 | No | Inclusive lower bound on published_at. |
before | ISO-8601 | No | Exclusive upper bound. |
limit | int 1–200 | No | Default 50. |
cursor | string | No | From the previous next_cursor. |
404 NOT_FOUND if :token is not on the roster.
Extra asset block on each row
interface AssetHeadlineRow extends Article {
asset: {
ticker: string;
token: string;
property_score: number;
property_sentiment: number; // this asset’s direction
sentiment_score: number; // netted against the article’s market leg
relation: 'same_asset' | 'operator' | 'commodity' | 'demand' | 'same_city' | 'same_country' | 'sector';
why: string;
headline_for_asset: string;
breaking_for_asset: boolean;
};
}relation is verified against the article text; an unsupported claim is downgraded to the strongest relation the text does support.
GET /v1/articles/:id/assets returns the same grades strongest-first, for a detail view. The list already includes resolved assets badges, so you do not need one request per row.
WebSocket
wss://news.loafmarkets.com/v1/ws/headlines
One connection. Send JSON client messages; handle server frames by type. Heartbeat every 30s. Two missed protocol pongs and the server closes with 1011.
Subscribe to headlines
{
"type": "subscribe",
"filters": {
"min_impact": 5
},
"last_seen_id": "a-8f3c21"
}filters (all optional):
| Field | Meaning |
|---|---|
suburbs | string[] |
region | central | eastern | north | western |
categories | string[] of category values |
min_impact | int 0–10 |
token | Loaf tokenName or ticker. Only articles graded to that asset, delivered after grading, not at ingest. Omit this unless you want a per-asset stream. |
min_property_score | int 0–10, used with token |
Tab filtering (feed_type) is REST-only. On the socket, use categories, min_impact, or token.
last_seen_id triggers a replay of what you missed. Replay frames are ordinary article messages (dedupe on id), then replay_complete.
const ws = new WebSocket('wss://news.loafmarkets.com/v1/ws/headlines');
let lastSeenId: string | undefined;
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'subscribe',
filters: { min_impact: 5 },
last_seen_id: lastSeenId,
}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(String(event.data));
if (msg.type === 'article') {
lastSeenId = msg.article.id;
// handle msg.article
} else if (msg.type === 'replay_complete' && (msg.truncated || msg.reason === 'cursor_expired')) {
// hole in history — re-seed GET /v1/headlines
}
};
ws.onclose = () => setTimeout(() => { /* reconnect with lastSeenId */ }, 3000);Server frames
type | When |
|---|---|
heartbeat | Every 30s while subscribed (server_time) |
subscribed | After a valid subscribe |
article | { article: Article } — live or replay. Per-asset subscribers also get the asset block. |
replay_complete | End of backfill. count, optional truncated, optional reason: cursor_expired | error |
error | { code, message } |
sentiment_subscribed / sentiment_update | After subscribe_sentiment |
prices_subscribed / price_update | After subscribe_prices |
If replay_complete.truncated is true, you hit the 200-article replay ceiling. Re-seed from REST rather than assuming you are caught up. reason: "cursor_expired" means last_seen_id aged out of the 7-day window.
Optional: sentiment and prices on the same socket
{ "type": "subscribe_sentiment", "scopes": [{ "type": "global" }] }{ "type": "subscribe_prices", "instrument_ids": ["BRENT", "US10Y", "TSLA"] }instrument_ids are price-feed ids (BRENT, US10Y), not article.tickers and not Loaf tickers. If the price feed is off, the server replies error / PRICE_FEED_DISABLED instead of acking. A successful subscribe_prices is immediately followed by a snapshot price_update (the feed only pushes on change).
REST equivalents: GET /v1/sentiment?scope=global&window=24h (window: 1h | 6h | 24h | 7d; suburb/region need name) and GET /v1/prices?ids=BRENT,US10Y. Prices 503 PRICE_FEED_DISABLED or PRICES_UNAVAILABLE when the feed is off or still cold.
These are extras. A bot that wants news into a strategy only needs headlines REST + subscribe.