Persist closed candles in PostgreSQL and read them back - #1
Conversation
Snapshots only lived in one process's memory, so a restart lost them and the workspace had nothing to show when the endpoint was unreachable. A Python ingester reads the same documented candleSnapshot endpoint, applies the same boundary rules as the browser, and writes closed candles to PostgreSQL. (symbol, interval, open_time) is the primary key and the upsert suppresses no-op updates, so a repeated window inserts nothing, a corrected candle updates in place, and each pass reports inserted, updated and unchanged counts. Every attempt is recorded with the wall time measured around its fetch and write. GET /api/stored/:symbol reads those rows back for the existing chart, replay cursor and timeframe comparison. An empty store is a 404 with an instruction; an unreachable database is a 503, never a silent fall back to the live endpoint. Compose runs the database, ingester and app against a named volume. A second CI job ingests the recorded payload twice against a real PostgreSQL server, reads it back from a separate process, restarts the database and reads it again. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8b457304d9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ]; | ||
| export const SOURCE_INTERVALS = (source: MarketSource): Interval[] => | ||
| source.kind === "recording" | ||
| source.kind === "recording" || source.kind === "stored" |
There was a problem hiding this comment.
Select an available timeframe for stored sources
When a user selects any new Stored candles entry, MarketWorkspace.selectMarket initializes every non-recording source to 5m, while this branch restricts stored sources to 1h, 4h, and 1d; the post-load timeframe correction also runs only for public sources. Consequently, a successful stored response initially renders an empty chart with no selected timeframe or replay controls until the user manually clicks an available interval. Initialize stored sources to 1h or apply the loaded-frame fallback to them as well.
Useful? React with 👍 / 👎.
| timeout: 5s | ||
| retries: 12 | ||
| volumes: | ||
| - candles:/var/lib/postgresql/data |
There was a problem hiding this comment.
Mount the PostgreSQL 18 data directory
With the selected postgres:18-alpine image, PostgreSQL stores data beneath /var/lib/postgresql/18/docker, and the official image guidance for 18+ therefore requires mounting /var/lib/postgresql, not the legacy /var/lib/postgresql/data path (official image documentation). This named volume is mounted at an unused child path, so the actual cluster resides in an anonymous image volume and can be lost when the database container is recreated, defeating the persistence guarantee this compose setup introduces.
Useful? React with 👍 / 👎.
Why
Candle snapshots only lived in one process's memory. A restart lost them, and
when the upstream endpoint was unreachable the workspace had nothing to show.
There was no SQL database in the project at all.
What this adds
ingest/— a Python ingester reading the same documented HyperliquidcandleSnapshotendpoint the browser uses, applying the same boundary rules(
ingest/candles.pymirrorsapp/publicMarket.ts): a still-forming candle, acandle that does not span its interval, a non-numeric price or inconsistent
OHLC bounds are refused before any write. Rows sharing an opening timestamp
with different values are a conflict, not a duplicate, and reject the response.
ingest/schema.sql—(symbol, interval, open_time)is the primary key. Theupsert suppresses no-op updates, so each pass reports inserted, updated and
unchanged counts, and a corrected candle updates in place.
ingest_runsrecords every attempt, including failures, with the wall time measured around
its fetch and its write.
GET /api/stored/:symbol— reads those rows back for the existing chart,replay cursor and timeframe comparison. Selector group Stored candles.
An empty store is 404 with an instruction; an unreachable database is 503,
never a silent fall back to the live endpoint.
docker-compose.yml— database, ingester and app against a named volume.persistenceCI job against a real PostgreSQL service container.Evidence from CI
Run 34031460448,
both jobs green. From the persistence job log:
scripts/check_persistence.mjsthen read the rows back in a separate processthat never saw the ingester, and again after
docker restartof the PostgreSQLcontainer. Both times: rows came from the database, the stored frames passed the
chart's own
validateBarsunchanged, and a replay prefix aggregated to4hwithout pulling in a later candle.
Test counts: 44 Node tests (12 new), 24 Python tests (6 of them PostgreSQL
integration, skipped unless
DATABASE_URLis set).Limits
Closed candles only, so this is a persisted snapshot history and not a tick
feed. One ingester process; no retention, partitioning or backfill policy.
Reads are bounded to 300 bars per interval. The timings are wall-clock
measurements between explicit start and end points in one process — not
throughput, database server time, network time or exchange-to-screen latency,
and no synchronized clocks are involved. The counts describe one statement's
view, not concurrent ingesters. Vercel does not serve stored candles.