API reference
Programmatic access to hourly Bitcoin news windows, BTC price ticks,
model predictions, and the prediction leaderboard. Read endpoints
(GET) are publicly accessible up to a per-IP rate
limit; submit endpoints (POST) require a bearer key.
Pass a key on read endpoints and the rate limit lifts entirely.
https://trade-news.gptsci-supp.workers.dev
Authorization: Bearer <your-key>
application/json
60 req / min / IP (lifted with key)
1 · Authentication & rate limits
Read endpoints (GET /api/news, GET /api/news/:id,
GET /api/prices, GET /api/predictions,
GET /api/leaderboard) work anonymously up to
60 requests per minute per IP. Beyond that limit the
server replies with 429 Too Many Requests and a
Retry-After header.
Every response carries quota headers so clients can throttle themselves:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1780668060
Submitting data (POST /api/news,
POST /api/predictions) always requires a
write or admin bearer token. Passing any
valid bearer token on a read endpoint removes the rate
limit, which is recommended for collectors and automation.
Tokens come in three scopes:
A predict key is the least-privilege option for a
forecasting model: it can submit predictions and read public data
(downloads bypass the anonymous rate limit), but cannot post news
windows or touch admin endpoints.
A key is shown in plaintext exactly once at mint time and only its SHA-256 hash is stored. If you lose a key, mint a new one and revoke the old; there is no recovery path.
Get a key
Ask the hub operator. Keys are minted by an admin via the bootstrap
admin endpoint. The label you choose becomes
the model attribution on every prediction you submit with that key —
pick it carefully (it's how your model shows up on the leaderboard).
The repo ships a helper script that wraps the curl call, validates the scope, prompts for the bootstrap key without echoing it, and prints the new key in a save-it-now panel:
# From the repo root
./mint-key.sh --label live_v1 --scope predict
# Or run interactively — the script prompts for anything missing:
./mint-key.sh
The raw curl below does the same thing if you prefer:
# A least-privilege prediction-only key (recommended for forecasters):
curl -X POST "$BASE/api/admin/keys" \
-H "Authorization: Bearer $ADMIN_BOOTSTRAP_KEY" \
-H "Content-Type: application/json" \
-d '{"label":"live_v1","scopes":"predict"}'
# 201
# {
# "id": 7,
# "label": "live_v1",
# "scopes": "predict",
# "api_key": "tn_live_…",
# "note": "Store this key now — it cannot be retrieved again."
# }
Once you have a key, paste it into the topbar "key" pill on the
dashboard to use it from this browser, or set
TRADE_NEWS_API_KEY in your shell for the
curl examples below.
2 · Submit data
Submission requires a write or admin-scoped
key. There are two write endpoints — one per hourly news window, and
one per model prediction.
POST /api/news
Send one hourly bundle. The hub stamps the window to the hour that just
closed on the server clock (it ignores any
window_start / window_end /
collected_at you send), stores the parent row plus each
item, and returns the window id.
The hub keeps one window per hour. The first POST of
an hour creates it (201, status:"stored");
later POSTs for the same hour fold in only the items not already
present — deduped by headline+source — and
the window's aggregate features are recomputed from the full item set
(200, status:"merged"). An exact
resubmission adds nothing (200,
status:"duplicate"), so retries are safe.
item_count in the response is the number of
new items stored by that call.
curl -X POST "$BASE/api/news" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY" \
-H "Content-Type: application/json" \
--data @hourly-record.json
# 201 { "window_id": 42, "item_count": 5, "status": "stored" } first POST of the hour
# 200 { "window_id": 42, "item_count": 2, "status": "merged" } +2 new items folded in
# 200 { "window_id": 42, "item_count": 0, "status": "duplicate" } exact retry, no-op
Body schema (excerpt)
{
// window_start / window_end / collected_at are optional and IGNORED —
// the server timestamps each bundle to the hour it was received.
"quiet_period": false,
"market_context": {
"btc_spot_price": 74250, "btc_price_change_1h_pct": 0.42,
"btc_volume_1h": 720, "dxy": 105.1, "funding_rate": 0.0002
},
"aggregate_features": {
"net_sentiment": 0.62, "total_impact": 0.41,
"item_count": 4, "max_single_impact": 0.18,
"bullish_count": 3, "bearish_count": 1,
"regulatory_flag": 1, "event_shock_flag": 0,
"category_distribution": {"regulatory": 1, "institutional_flows": 1}
},
"items": [
{
"headline": "...", "source": "Reuters", "url": "...",
"timestamp": "2026-06-01T13:08Z", "summary": "...",
"category": "regulatory", "scope": "bitcoin_specific",
"impact_score": 5, "sentiment_score": 5, "sentiment_confidence": 5,
"source_credibility": 5, "novelty_score": 5, "recency_score": 5,
"mention_volume": 6, "quantitative_value": null, "quantitative_unit": null,
"weighted_impact": 1.0
}
]
}
POST /api/predictions
Forecast BTC price one hour after the window's end. The hub fills
reference_price / reference_time from the window if
omitted; the grader cron settles each pending prediction once the
target time passes.
model
field in the request body. The prediction's model name is taken
from the label on the API key you authenticate with at
submit time. To submit under the name live_v1, mint a
key with {"label":"live_v1","scopes":"predict"} and
post with that key — the response and leaderboard will both say
live_v1. A single model can publish multiple
named values per window (point/max/min/quantiles) by setting
the value field, described below.
Body fields:
predicted_price— required, positive number. Your BTC forecast for the target time.target_time— optional, epoch ms (number) or ISO-8601 string. When the forecast is supposed to hold. Default isreference_time + 1h(the legacy "1 hour ahead" cadence), but you can submit any future time — the grader will settle the prediction once that timestamp passes. Must be strictly afterreference_time.window_id— optional integer. Pin the prediction to a specific window. Omit it to predict against the latest window automatically — usually what live collectors want.value— optional string, default"point". Names the series this prediction belongs to within the model. Use it to publish multiple values per window from one key — common choices:point,max,min,median,q05,q95. Allowed: lowercase a–z, 0–9,_and-, up to 50 chars. The leaderboard groups by (model, value), so each named series gets its own accuracy row.target_symbol— optional, defaults to"BTC".reference_price— optional override; defaults to the window'sbtc_spot_price.reference_time— optional, epoch ms or ISO-8601. Defaults to the window'swindow_end_ms. The "starting point" the direction-correctness metric is computed against (sign(predicted − reference) === sign(actual − reference)).
The response always echoes the resolved target_time and a submitted_at timestamp (server-recorded epoch ms when the row was inserted) so callers can audit the round-trip.
Point prediction example — let the hub default target_time to reference_time + 1h:
curl -X POST "$BASE/api/predictions" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"predicted_price": 74600}'
# 201
# {
# "id": 81, "model": "live_v1", "value": "point",
# "window_id": 42, "reference_price": 74250,
# "reference_time": 1780668000000,
# "target_time": 1780671600000,
# "submitted_at": 1780668003121,
# "status": "pending"
# }
Explicit target time — forecast 4 hours out using an ISO timestamp:
curl -X POST "$BASE/api/predictions" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"predicted_price": 75100,
"target_time": "2026-06-01T18:00:00Z"
}'
Range / multi-value example — same key (live_v1), three named values per window:
# Upper bound
curl -X POST "$BASE/api/predictions" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"predicted_price": 75200, "value": "max"}'
# Lower bound
curl -X POST "$BASE/api/predictions" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"predicted_price": 74100, "value": "min"}'
# Median (also the point estimate)
curl -X POST "$BASE/api/predictions" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"predicted_price": 74650, "value": "median"}'
Filter the predictions listing by value, too:
curl "$BASE/api/predictions?model=live_v1&value=max&status=graded" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY"
Or pin the prediction to a specific window you've inspected:
# Find the window_id first (newest is items[0].id)
WINDOW_ID=$(curl -s "$BASE/api/news?limit=1" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY" \
| jq -r '.items[0].id')
curl -X POST "$BASE/api/predictions" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"window_id\": $WINDOW_ID, \"predicted_price\": 74600}"
POSTs are not idempotent — calling
/api/predictions twice with the same payload creates
two pending predictions. Submit each forecast exactly once per
window per model.
3 · Download data
Download endpoints accept any active bearer token, or work
anonymously under the per-IP rate limit. All list endpoints are
newest-first and cursor-paginated: pass the next_cursor
from one response back as before to fetch the next page.
The examples below include the Authorization header — it's
optional, but omitting it counts the call against your IP quota.
GET /api/news
List recent windows, ordered by news time (most recent
window_end first) — so items[0] is always the
latest window, even if windows were submitted out of order.
curl "$BASE/api/news?limit=20" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY"
# 200
# {
# "items": [
# {
# "id": 42, "window_end": "2026-06-01T14:00Z", "window_end_ms": 1748786400000,
# "btc_spot_price": 74250, "net_sentiment": 0.62,
# "total_impact": 0.41, "item_count": 4,
# "bullish_count": 3, "bearish_count": 1,
# "regulatory_flag": 1, "event_shock_flag": 0, "quiet_period": 0,
# "category_distribution": {"regulatory": 1, "institutional_flows": 1},
# ...
# }
# ],
# "next_cursor": "1748786400000_42"
# }
Query params: limit (1–200, default 50), before (cursor — pass the prior next_cursor, a "<window_end_ms>_<id>" string).
GET /api/news/:id
One window plus all of its items.
curl "$BASE/api/news/42" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY"
# 200
# {
# "id": 42, "window_end": "2026-06-01T14:00Z", ...,
# "items": [
# {"headline": "...", "category": "regulatory", "weighted_impact": 1.0, ...}
# ]
# }
GET /api/prices
BTC tick series for charting. Params: symbol (default BTC), hours (1–168, default 24).
curl "$BASE/api/prices?hours=4" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY"
# 200
# { "symbol": "BTC", "ticks": [{"t": 1780664400000, "price": 74250, "source": "coinbase"}, ...] }
GET /api/predictions
List predictions. Params: limit, before, status (pending|graded|expired), model.
curl "$BASE/api/predictions?status=graded&limit=25" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY"
GET /api/leaderboard
Per-(model, value) forecast scorecard across graded predictions. Each
named series gets its own row, so a range predictor that posts
max and min under one key has two
leaderboard entries. Rows are ranked by skill_score
(best first).
Each row reports:
skill_score—1 − rmse / baseline_rmsevs. the no-change (persistence) baseline.>0beats the baseline,0ties it,<0is worse.nullwhen the baseline never moved.rmse/mae— root-mean-square and mean absolute error, in USD.mape— mean absolute percentage error.bias— mean signed error in USD (positive = over-forecasts).direction_accuracy_pct— share of forecasts that called the up/down move correctly.baseline_rmse/baseline_mae— the persistence baseline's error, for reference.
curl "$BASE/api/leaderboard" \
-H "Authorization: Bearer $TRADE_NEWS_API_KEY"
# 200
# {
# "leaderboard": [
# {"rank": 1, "model": "alpha", "value": "point", "graded_count": 17,
# "skill_score": 0.53, "rmse": 128.0, "mae": 116.7, "mape": 0.74,
# "bias": 56.7, "direction_accuracy_pct": 70.5,
# "baseline_rmse": 270.9, "baseline_mae": 243.3}
# ],
# "baseline": {
# "name": "no-change (persistence)",
# "description": "Naive forecast that the price equals the reference price ..."
# }
# }
GET /api/health
The single unauthenticated endpoint. Returns server time, the timestamp of the most recent price tick, and pending-prediction counts — used by the dashboard health pill.
4 · Status codes
201 Created— submission accepted.200 OK— download succeeded.400 Bad Request— body or query failed schema validation; the response includes aissuesarray describing each failure.401 Unauthorized— missing or invalid bearer token.403 Forbidden— token is valid but doesn't carry the required scope (e.g. read-only key trying to POST).404 Not Found— requested id doesn't exist.409 Conflict— predicting against a non-existent latest window.429 Too Many Requests— anonymous read quota exceeded; checkRetry-Afteror use a bearer key to bypass the limit.500 Internal Error— see Worker logs / try again.
5 · End-to-end: submit a live prediction
Full flow to submit a BTC forecast under the model name
live_v1 and check that it landed on the leaderboard.
BASE=https://trade-news.gptsci-supp.workers.dev
# 1. One-time: ask the admin to mint a key labelled "live_v1".
# The label is what shows up as the model name — pick it now.
# "predict" scope is least-privilege (can submit predictions only).
curl -s -X POST "$BASE/api/admin/keys" \
-H "Authorization: Bearer $ADMIN_BOOTSTRAP_KEY" \
-H "Content-Type: application/json" \
-d '{"label":"live_v1","scopes":"predict"}'
# → { "id": 7, "label": "live_v1", "scopes": "predict", "api_key": "tn_live_…" }
export KEY=tn_live_… # paste the api_key value returned above
# 2. Every hour after a new window posts, submit a prediction.
# Omit window_id and the hub pins it to the latest window.
curl -s -X POST "$BASE/api/predictions" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"predicted_price": 74600}'
# → { "id": 81, "model": "live_v1", "window_id": 42, "status": "pending", ... }
# 3. (Optional) Range / multi-value publisher: post several named values
# under the same key by setting "value".
curl -s -X POST "$BASE/api/predictions" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"predicted_price": 75200, "value": "max"}'
curl -s -X POST "$BASE/api/predictions" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"predicted_price": 74100, "value": "min"}'
# 4. After the target time (~1h later) the grader settles each prediction.
# Filter by (model, value) to inspect just one series:
curl -s "$BASE/api/predictions?model=live_v1&value=max&limit=10" \
-H "Authorization: Bearer $KEY" \
| jq '.items[] | {predicted_price, actual_price, error_pct, status}'
# 5. Each named series shows up as its own leaderboard row:
curl -s "$BASE/api/leaderboard" \
| jq '.leaderboard[] | select(.model == "live_v1")'