Candle REST API
The candle REST API exposes historical OHLC price bars, market roster metadata, live match state, and fixture schedules. It is the easiest entry point for building charts, dashboards, or backtesting tools.
| Host (devnet, raw) | http://178.104.120.151:3456 |
| HTTPS (Caddy reverse proxy) | https://api.sportsperp.xyz |
| HTTPS (Next.js proxy, browser clients) | https://app.sportsperp.xyz/api/candles/… |
| Source | engine/candle-api.ts |
| Storage | SQLite (engine/candle-store.ts) |
| CORS | Wide-open (Access-Control-Allow-Origin: *, GET only) |
HTTPS / mixed-content note
The raw service serves HTTP only. Browser clients running on HTTPS cannot call http://178.104.120.151:3456 directly — the browser will block it as mixed content. Two HTTPS-safe paths exist:
- Server-side / Node.js / cURL: hit the raw host on port 3456, or
https://api.sportsperp.xyzfor TLS. - Browser: route through the Next.js API proxies in
app/src/app/api/—/api/candles/[marketKey],/api/roster,/api/fixtures. The proxies’ upstream is configured viaCANDLE_BACKEND_URL(defaults tohttps://api.sportsperp.xyz).
Endpoints
GET /api/candles/:marketKey
Returns OHLC bars for a single market.
Path parameters:
| Param | Example | Format |
|---|---|---|
marketKey | team-arsenal, player-saka | See “Market keys” below |
Query parameters:
| Param | Default | Range |
|---|---|---|
timeframe | 1H | 1m · 1H · 4H · 1D |
limit | 200 | Clamped to min(limit, 1000) |
Example:
curl "http://178.104.120.151:3456/api/candles/team-arsenal?timeframe=1H&limit=50"Response — a raw array of OHLC bars (no envelope, no volume):
[
{ "time": 1745280000, "open": 598.4, "high": 604.1, "low": 597.9, "close": 602.3 },
{ "time": 1745283600, "open": 602.3, "high": 605.0, "low": 601.7, "close": 604.5 }
]time is Unix seconds at bar start. The store records 1m bars (one per crank tick) and aggregates higher timeframes on read. Trading volume is not yet tracked server-side and is not part of the response.
Invalid timeframe returns HTTP 400 with { "error": "Invalid timeframe. Use: 1m, 1H, 4H, 1D" }.
GET /api/candles/stats
High-level stats about the candle store.
{
"totalCandles": 184302,
"markets": 68,
"oldestTimestamp": 1733000000,
"newestTimestamp": 1745400000
}oldestTimestamp / newestTimestamp are Unix seconds and may be null if the store is empty. Useful for health checks and capacity planning.
GET /api/roster
Full roster.json contents — the single source of truth for market metadata.
{
"roster_version": 4,
"competition_id": 2,
"season_id": 281,
"teams": [
{ "market_id": 0, "name": "Arsenal", "short_name": "ARS", "source_team_id": 1, "...": "..." }
],
"players": [
{ "market_id": 20, "name": "Bukayo Saka", "team_id": 1, "position_group": "Forward", "sb_player_id": 39461, "...": "..." }
]
}v4 adds sb_player_id on every player entry to harden market ↔ StatsBomb identity across transfers and roster rotations. Clients should fetch this at startup or on a long interval — the roster changes weekly, not per-second. The engine itself hot-reloads via fs.watch, so the served data is always current.
GET /api/fixtures
Next upcoming or in-progress match per team market ID.
{
"fixtures": {
"0": { "kickoff": 1745452800, "opponent": "Chelsea", "home": true },
"1": { "kickoff": 1745539200, "opponent": "Manchester City", "home": false }
}
}Keys are team-market IDs as strings (player markets do not appear here — fixtures are per-team). kickoff is Unix seconds UTC. The window includes matches that kicked off up to 3 hours ago so callers can also use this endpoint to detect live matches. Cached server-side for 1 hour; if the upstream StatsBomb fetch fails, the previous cached snapshot is returned (or {} if there is none).
GET /api/live/matches
Active and recently completed matches with per-team/player live OBV aggregates.
{
"active": [
{
"matchId": 1371134,
"homeTeam": "Crystal Palace",
"awayTeam": "West Ham",
"homeTeamId": 13,
"awayTeamId": 20,
"eventsProcessed": 1347,
"teamObv": { "13": 0.12, "20": -0.08 },
"playerObv": { "45": 0.03, "67": -0.01 }
}
],
"completed": [
{
"matchId": 1371129,
"homeTeam": "Arsenal",
"awayTeam": "Everton",
"officialDataExpectedAt": 1745382000
}
],
"wsConnections": 12
}teamObv / playerObv keys are REST IDs (the canonical IDs used in roster.json); the Live↔REST ID bridge translates live-side IDs before they appear here. If the crank has not yet attached a LiveProcessor, the endpoint returns { "active": [], "completed": [] }.
GET /health / GET /api/health
{
"status": "ok",
"uptime": 842391,
"lastCycleAt": "2026-05-12T11:30:00.000Z",
"crankCycles": 4317,
"wsConnections": 12,
"liveObvSources": [
{
"matchId": 1371134,
"team": { "source": "authoritative", "reason": "obv-engine returned totals", "since": 1745399700000 },
"player": { "source": "authoritative", "reason": "obv-engine returned totals", "since": 1745399700000 }
}
],
"liveProcessor": {
"lastSuccessfulPollMs": 1745399945123,
"pollErrorsInLastMinute": 0,
"lastPollSummary": "12 matches polled, 1 active"
},
"shadowObv": null,
"idBridge": null
}shadowObv is present only when the crank runs V2 in shadow mode (SHADOW_REALTIME_OBV=true). idBridge is present when the Live↔REST ID bridge has registered a provider. liveProcessor is null when no live processor is attached.
Returns HTTP 200 unconditionally — the monitor service parses the body. Use status === "ok" as the readiness signal.
Market keys
Market keys are the URL-safe identifier format:
| Format | Example |
|---|---|
team-{slug} | team-arsenal, team-manchester-united |
player-{slug} | player-saka, player-haaland |
The slug is the team or player name lowercased, diacritics stripped, spaces → hyphens. Both the exact and normalized variants are stored in engine/market-map.ts so lookups by either resolve cleanly.
Always prefer market_id for programmatic access. Use /api/roster to resolve name → ID and then build the key, or store the key as returned by the roster — IDs are the authoritative identifier and survive renames.
Timeframes and aggregation
The crank writes a tick every cycle (every 5 minutes outside live windows; faster during live matches as event-driven updates arrive). The store records raw 1-minute bars and aggregates on read:
| Timeframe | Bucket | Bars per crank cycle outside live |
|---|---|---|
1m | 60 s | 1 (sparse — only populated for recorded ticks) |
1H | 3600 s | every 12 ticks |
4H | 14400 s | every 48 ticks |
1D | 86400 s | every 288 ticks |
Bar boundaries are aligned to UTC midnight (floor(time / bucket) * bucket). 1m candles may be sparse between the 5-minute oracle ticks — charts typically render 1H or 4H for the main view and 1m only during live matches where event-driven updates populate the gaps.
Rate limits
No hard rate limit today. The service runs on a single Hetzner CPX22 (8 GB RAM, 3 vCPU) and comfortably serves ~100 req/s. Be a good citizen:
- Cache the roster response client-side — it changes at most weekly.
- Poll candles no more often than the bar duration allows.
- Use the WebSocket feed for real-time updates instead of polling — see WebSocket Feed.
If you are planning high-volume consumption (backtesting, academic research), reach out — we can provide a direct dump from the SQLite store rather than hammering the API.
Historical backfill gaps
The candle store is append-only since the crank was deployed. Markets initialized after deployment only have candles from that point forward. There is no backfill of candles from before a market’s on-chain initialization — even if the engine calculated indices privately, they were not persisted.
If you need longer historical data for modeling, the underlying event data is available directly from the source vendor with appropriate credentials; composite index values can be replayed using engine/index-calculator.ts against historical inputs.
Further reading
- WebSocket Feed — real-time updates, complementary to this REST API.
- SDK — typed client for the on-chain program (the complementary “state” API to this REST “data” API).
engine/candle-api.ts— source.