-
Notifications
You must be signed in to change notification settings - Fork 0
Persist closed candles in PostgreSQL and read them back #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # The CI job regenerates this fixture and compares it to the committed copy, | ||
| # so its line endings must not depend on the checkout platform. | ||
| ingest/fixtures/public-candles.json text eol=lf |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,4 +3,5 @@ node_modules/ | |
| .next/ | ||
| .env* | ||
| *.tsbuildinfo | ||
| __pycache__/ | ||
| .github/ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { candleQuery } from "../../../db"; | ||
| import { createStoredApi } from "../../../storedApi"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const GET = createStoredApi(candleQuery); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { Pool } from "pg"; | ||
| import type { CandleQuery } from "./storedApi.ts"; | ||
|
|
||
| // GUESS: UNCALIBRATED GUESS — small pool and bounded waits for a single | ||
| // container. Not measured capacity. | ||
| const POOL_SIZE = 4; | ||
| const CONNECT_TIMEOUT_MS = 5_000; | ||
| const IDLE_TIMEOUT_MS = 30_000; | ||
|
|
||
| let pool: Pool | undefined; | ||
|
|
||
| /** Created on first use, so a build or a page render without DATABASE_URL still works. */ | ||
| function candlePool() { | ||
| const connectionString = process.env.DATABASE_URL; | ||
| if (!connectionString) throw new Error("DATABASE_URL is not set."); | ||
| pool ??= new Pool({ | ||
| connectionString, | ||
| max: POOL_SIZE, | ||
| connectionTimeoutMillis: CONNECT_TIMEOUT_MS, | ||
| idleTimeoutMillis: IDLE_TIMEOUT_MS, | ||
| }); | ||
| return pool; | ||
| } | ||
|
|
||
| export const candleQuery: CandleQuery = async (text, values) => | ||
| (await candlePool().query(text, values)).rows; | ||
|
|
||
| /** Release the pool so a one-shot script can exit. Not used by the server. */ | ||
| export async function closeCandlePool() { | ||
| const open = pool; | ||
| pool = undefined; | ||
| await open?.end(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { PUBLIC_SYMBOLS } from "./marketApi.ts"; | ||
| import { STORED_BARS, STORED_INTERVALS, STORED_QUERY, toFrames, type StoredRow } from "./storedMarket.ts"; | ||
|
|
||
| export type CandleQuery = (text: string, values: unknown[]) => Promise<StoredRow[]>; | ||
|
|
||
| /** Read persisted candles for one allowlisted market. | ||
| * An empty store is a normal state before the ingester has run, so it answers | ||
| * 404 with an instruction rather than pretending the database is broken. | ||
| */ | ||
| export function createStoredApi(query: CandleQuery, now = Date.now) { | ||
| return async (input: Request): Promise<Response> => { | ||
| const symbol = new URL(input.url).pathname.split("/").at(-1) ?? ""; | ||
| if (!PUBLIC_SYMBOLS.includes(symbol as typeof PUBLIC_SYMBOLS[number])) { | ||
| return Response.json({ error: "Choose BTC, ETH or SOL." }, { status: 400 }); | ||
| } | ||
| const started = now(); | ||
| try { | ||
| const rows = await query(STORED_QUERY, [symbol, STORED_INTERVALS, STORED_BARS]); | ||
| const { frames, counts, asOf } = toFrames(rows); | ||
| if (!Object.keys(frames).length) { | ||
| return Response.json( | ||
| { error: "No candles are stored for this market yet. Run the ingester, then reload." }, | ||
| { status: 404, headers: { "Cache-Control": "no-store" } }); | ||
| } | ||
| return Response.json({ ...{ frames, asOf }, symbol, venue: "Hyperliquid", stored: counts, source: "database" }, { | ||
| headers: { | ||
| "Cache-Control": "no-store", | ||
| // SOURCE: measured query and mapping wall time inside this handler. | ||
| // Not database server time, network time or end-to-end latency. | ||
| "Server-Timing": `query;dur=${Math.max(0, now() - started)}`, | ||
| }, | ||
| }); | ||
| } catch { | ||
| return Response.json({ error: "Stored candles are unavailable. Check the database connection." }, | ||
| { status: 503, headers: { "Cache-Control": "no-store" } }); | ||
| } | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { INTERVAL_MS, type Bar, type Interval } from "./marketAnalysis.ts"; | ||
|
|
||
| /** Intervals the ingester persists by default. */ | ||
| export const STORED_INTERVALS: Interval[] = ["1h", "4h", "1d"]; | ||
| // GUESS: UNCALIBRATED GUESS — read bound per interval, matching the existing | ||
| // display history in app/publicSnapshot.ts. Not a trading lookback. | ||
| export const STORED_BARS = 300; | ||
|
|
||
| /** One round trip for every interval: rank each interval's rows, keep the newest. | ||
| * The primary key already prevents duplicate candles, so no grouping is needed here. | ||
| */ | ||
| export const STORED_QUERY = ` | ||
| SELECT interval, open_time, close_time, "open", "high", "low", "close", volume | ||
| FROM ( | ||
| SELECT interval, open_time, close_time, "open", "high", "low", "close", volume, | ||
| row_number() OVER (PARTITION BY interval ORDER BY open_time DESC) AS position | ||
| FROM candles | ||
| WHERE symbol = $1 AND interval = ANY($2) | ||
| ) ranked | ||
| WHERE position <= $3 | ||
| ORDER BY interval, open_time | ||
| `; | ||
|
|
||
| export type StoredRow = Record<string, unknown>; | ||
|
|
||
| /** node-postgres returns bigint columns as strings, so widen then check the range. */ | ||
| function epochMilliseconds(value: unknown) { | ||
| const result = typeof value === "string" ? Number(value) : value; | ||
| if (typeof result !== "number" || !Number.isSafeInteger(result) || result < 0) | ||
| throw new Error("Stored candle has an unusable timestamp."); | ||
| return result; | ||
| } | ||
|
|
||
| function price(value: unknown) { | ||
| const result = typeof value === "string" ? Number(value) : value; | ||
| if (typeof result !== "number" || !Number.isFinite(result)) | ||
| throw new Error("Stored candle has an unusable price or volume."); | ||
| return result; | ||
| } | ||
|
|
||
| /** Group stored rows into per-interval frames without trusting the row contents. | ||
| * A stored row still has to describe a whole candle of its interval; anything | ||
| * else is a storage fault and is reported rather than charted. | ||
| */ | ||
| export function toFrames(rows: Iterable<StoredRow>) { | ||
| const frames: Partial<Record<Interval, Bar[]>> = {}; | ||
| const byInterval = new Map<Interval, Map<number, Bar>>(); | ||
| let asOf = 0; | ||
| for (const row of rows) { | ||
| const interval = row.interval as Interval; | ||
| if (!STORED_INTERVALS.includes(interval)) continue; | ||
| const t = epochMilliseconds(row.open_time); | ||
| const closeTime = epochMilliseconds(row.close_time); | ||
| if (closeTime !== t + INTERVAL_MS[interval]) | ||
| throw new Error("Stored candle does not span its interval."); | ||
| const o = price(row.open), h = price(row.high), l = price(row.low); | ||
| const c = price(row.close), v = price(row.volume); | ||
| if (l <= 0 || h < Math.max(o, c, l) || l > Math.min(o, c) || v < 0) | ||
| throw new Error("Stored candle has inconsistent bounds."); | ||
| // Only closed candles are ever persisted, so a stored row is closed by construction. | ||
| const bar: Bar = { t, closeTime, o, h, l, c, v, closed: true }; | ||
| if (!byInterval.has(interval)) byInterval.set(interval, new Map()); | ||
| byInterval.get(interval)!.set(t, bar); | ||
| if (closeTime > asOf) asOf = closeTime; | ||
| } | ||
| const counts: Partial<Record<Interval, number>> = {}; | ||
| for (const [interval, bars] of byInterval) { | ||
| frames[interval] = [...bars.values()].sort((a, b) => a.t - b.t); | ||
| counts[interval] = frames[interval]!.length; | ||
| } | ||
| // The cutoff is the newest stored close, so a restart replays what is on disk | ||
| // rather than assuming the ingester is currently running. | ||
| return { frames, counts, asOf }; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user selects any new Stored candles entry,
MarketWorkspace.selectMarketinitializes every non-recording source to5m, while this branch restricts stored sources to1h,4h, and1d; 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 to1hor apply the loaded-frame fallback to them as well.Useful? React with 👍 / 👎.