From cd07cf1cbd8df9a5591b9002b2b68eae8d27fc2f Mon Sep 17 00:00:00 2001 From: Filip Hallqvist Date: Thu, 25 Sep 2025 10:07:55 +0200 Subject: [PATCH 1/7] Move some files around --- apps/insights/src/app/price-feeds/[slug]/(main)/loading.ts | 2 +- apps/insights/src/app/price-feeds/[slug]/(main)/page.ts | 2 +- .../components/PriceFeed/{ => Chart}/chart-page.module.scss | 0 .../src/components/PriceFeed/{ => Chart}/chart-page.tsx | 2 +- .../src/components/PriceFeed/{ => Chart}/chart.module.scss | 0 .../insights/src/components/PriceFeed/{ => Chart}/chart.tsx | 6 +++--- 6 files changed, 6 insertions(+), 6 deletions(-) rename apps/insights/src/components/PriceFeed/{ => Chart}/chart-page.module.scss (100%) rename apps/insights/src/components/PriceFeed/{ => Chart}/chart-page.tsx (98%) rename apps/insights/src/components/PriceFeed/{ => Chart}/chart.module.scss (100%) rename apps/insights/src/components/PriceFeed/{ => Chart}/chart.tsx (97%) diff --git a/apps/insights/src/app/price-feeds/[slug]/(main)/loading.ts b/apps/insights/src/app/price-feeds/[slug]/(main)/loading.ts index a1085ea612..62f0c87e75 100644 --- a/apps/insights/src/app/price-feeds/[slug]/(main)/loading.ts +++ b/apps/insights/src/app/price-feeds/[slug]/(main)/loading.ts @@ -1 +1 @@ -export { ChartPageLoading as default } from "../../../../components/PriceFeed/chart-page"; +export { ChartPageLoading as default } from "../../../../components/PriceFeed/Chart/chart-page"; diff --git a/apps/insights/src/app/price-feeds/[slug]/(main)/page.ts b/apps/insights/src/app/price-feeds/[slug]/(main)/page.ts index 165ad5abcd..10e4cc3c72 100644 --- a/apps/insights/src/app/price-feeds/[slug]/(main)/page.ts +++ b/apps/insights/src/app/price-feeds/[slug]/(main)/page.ts @@ -1,3 +1,3 @@ -export { ChartPage as default } from "../../../../components/PriceFeed/chart-page"; +export { ChartPage as default } from "../../../../components/PriceFeed/Chart/chart-page"; export const revalidate = 3600; diff --git a/apps/insights/src/components/PriceFeed/chart-page.module.scss b/apps/insights/src/components/PriceFeed/Chart/chart-page.module.scss similarity index 100% rename from apps/insights/src/components/PriceFeed/chart-page.module.scss rename to apps/insights/src/components/PriceFeed/Chart/chart-page.module.scss diff --git a/apps/insights/src/components/PriceFeed/chart-page.tsx b/apps/insights/src/components/PriceFeed/Chart/chart-page.tsx similarity index 98% rename from apps/insights/src/components/PriceFeed/chart-page.tsx rename to apps/insights/src/components/PriceFeed/Chart/chart-page.tsx index aa2797827d..32ce4fc071 100644 --- a/apps/insights/src/components/PriceFeed/chart-page.tsx +++ b/apps/insights/src/components/PriceFeed/Chart/chart-page.tsx @@ -5,7 +5,7 @@ import { Spinner } from "@pythnetwork/component-library/Spinner"; import { Chart } from "./chart"; import styles from "./chart-page.module.scss"; -import { getFeed } from "./get-feed"; +import { getFeed } from "../get-feed"; type Props = { params: Promise<{ diff --git a/apps/insights/src/components/PriceFeed/chart.module.scss b/apps/insights/src/components/PriceFeed/Chart/chart.module.scss similarity index 100% rename from apps/insights/src/components/PriceFeed/chart.module.scss rename to apps/insights/src/components/PriceFeed/Chart/chart.module.scss diff --git a/apps/insights/src/components/PriceFeed/chart.tsx b/apps/insights/src/components/PriceFeed/Chart/chart.tsx similarity index 97% rename from apps/insights/src/components/PriceFeed/chart.tsx rename to apps/insights/src/components/PriceFeed/Chart/chart.tsx index 478d1172cd..c2dfa2c1ff 100644 --- a/apps/insights/src/components/PriceFeed/chart.tsx +++ b/apps/insights/src/components/PriceFeed/Chart/chart.tsx @@ -10,9 +10,9 @@ import { useEffect, useRef, useCallback } from "react"; import { z } from "zod"; import styles from "./chart.module.scss"; -import { useLivePriceData } from "../../hooks/use-live-price-data"; -import { usePriceFormatter } from "../../hooks/use-price-formatter"; -import { Cluster } from "../../services/pyth"; +import { useLivePriceData } from "../../../hooks/use-live-price-data"; +import { usePriceFormatter } from "../../../hooks/use-price-formatter"; +import { Cluster } from "../../../services/pyth"; type Props = { symbol: string; From 3498c556621072c25cc1df61bbea965882291e0f Mon Sep 17 00:00:00 2001 From: Filip Hallqvist Date: Thu, 25 Sep 2025 17:25:16 +0200 Subject: [PATCH 2/7] feat: Add resolution/lookback support to charts --- .../src/app/historical-prices/route.ts | 103 +++- .../components/PriceFeed/Chart/chart-page.tsx | 3 +- .../PriceFeed/Chart/chart-toolbar.tsx | 95 ++++ .../PriceFeed/Chart/chart.module.scss | 8 +- .../src/components/PriceFeed/Chart/chart.tsx | 483 ++++++++++++------ .../PriceFeed/Chart/use-chart-toolbar.tsx | 58 +++ apps/insights/src/services/clickhouse.ts | 68 ++- 7 files changed, 643 insertions(+), 175 deletions(-) create mode 100644 apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx create mode 100644 apps/insights/src/components/PriceFeed/Chart/use-chart-toolbar.tsx diff --git a/apps/insights/src/app/historical-prices/route.ts b/apps/insights/src/app/historical-prices/route.ts index b989d7360d..2b7ea12276 100644 --- a/apps/insights/src/app/historical-prices/route.ts +++ b/apps/insights/src/app/historical-prices/route.ts @@ -1,17 +1,102 @@ import type { NextRequest } from "next/server"; +import { z } from "zod"; import { getHistoricalPrices } from "../../services/clickhouse"; +const queryParamsSchema = z.object({ + symbol: z.string().transform(decodeURIComponent), + publisher: z + .string() + .nullable() + .transform((value) => value ?? undefined), + cluster: z.enum(["pythnet", "pythtest"]), + from: z.string().transform(Number), + to: z.string().transform(Number), + resolution: z.enum(["1s", "1m", "5m", "1H", "1D"]).transform((value) => { + switch (value) { + case "1s": { + return "1 SECOND"; + } + case "1m": { + return "1 MINUTE"; + } + case "5m": { + return "5 MINUTE"; + } + case "1H": { + return "1 HOUR"; + } + case "1D": { + return "1 DAY"; + } + } + }), +}); + export async function GET(req: NextRequest) { - const symbol = req.nextUrl.searchParams.get("symbol"); - const until = req.nextUrl.searchParams.get("until"); - if (symbol && until) { - const res = await getHistoricalPrices({ - symbol: decodeURIComponent(symbol), - until, + const parsed = queryParamsSchema.safeParse( + Object.fromEntries( + Object.keys(queryParamsSchema.shape).map((key) => [ + key, + req.nextUrl.searchParams.get(key), + ]), + ), + ); + if (!parsed.success) { + return new Response(`Invalid params: ${parsed.error.message}`, { + status: 400, + }); + } + + const { symbol, publisher, cluster, from, to, resolution } = parsed.data; + + try { + checkMaxDataPointsInvariant(from, to, resolution); + } catch { + return new Response("Unsupported resolution for date range", { + status: 400, }); - return Response.json(res); - } else { - return new Response("Must provide `symbol` and `until`", { status: 400 }); + } + + const res = await getHistoricalPrices({ + symbol, + from, + to, + publisher, + cluster, + resolution, + }); + + return Response.json(res); +} + +const MAX_DATA_POINTS = 3000; +function checkMaxDataPointsInvariant( + from: number, + to: number, + resolution: "1 SECOND" | "1 MINUTE" | "5 MINUTE" | "1 HOUR" | "1 DAY", +) { + let diff = to - from; + switch (resolution) { + case "1 MINUTE": { + diff = diff / 60; + break; + } + case "5 MINUTE": { + diff = diff / 60 / 5; + break; + } + case "1 HOUR": { + diff = diff / 3600; + break; + } + case "1 DAY": { + diff = diff / 86_400; + break; + } + } + + if (diff > MAX_DATA_POINTS) { + throw new Error("Unsupported resolution for date range"); } } diff --git a/apps/insights/src/components/PriceFeed/Chart/chart-page.tsx b/apps/insights/src/components/PriceFeed/Chart/chart-page.tsx index 32ce4fc071..a60cfe9506 100644 --- a/apps/insights/src/components/PriceFeed/Chart/chart-page.tsx +++ b/apps/insights/src/components/PriceFeed/Chart/chart-page.tsx @@ -6,6 +6,7 @@ import { Spinner } from "@pythnetwork/component-library/Spinner"; import { Chart } from "./chart"; import styles from "./chart-page.module.scss"; import { getFeed } from "../get-feed"; +import { ChartToolbar } from "./chart-toolbar"; type Props = { params: Promise<{ @@ -26,7 +27,7 @@ type ChartPageImplProps = }); const ChartPageImpl = (props: ChartPageImplProps) => ( - + }>
{props.isLoading ? (
diff --git a/apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx b/apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx new file mode 100644 index 0000000000..48c003eb9e --- /dev/null +++ b/apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx @@ -0,0 +1,95 @@ +"use client"; +import { Select } from "@pythnetwork/component-library/Select"; +import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup"; +import { useLogger } from "@pythnetwork/component-library/useLogger"; +import { useCallback } from "react"; +import type { Key } from "react-aria"; + +import type { Lookback, Resolution } from "./use-chart-toolbar"; +import { + LOOKBACK_TO_RESOLUTION, + LOOKBACKS, + RESOLUTION_TO_LOOKBACK, + RESOLUTIONS, + useChartLookback, + useChartResolution, +} from "./use-chart-toolbar"; + +export const ChartToolbar = () => { + const logger = useLogger(); + const [lookback, setLookback] = useChartLookback(); + const [resolution, setResolution] = useChartResolution(); + + const handleLookbackChange = useCallback( + (newValue: Key) => { + if (!isLookback(newValue)) { + throw new TypeError("Invalid lookback"); + } + const lookback: Lookback = newValue; + setLookback(lookback).catch((error: unknown) => { + logger.error("Failed to update lookback", error); + }); + setResolution(LOOKBACK_TO_RESOLUTION[lookback]).catch( + (error: unknown) => { + logger.error("Failed to update resolution", error); + }, + ); + }, + [logger, setLookback, setResolution], + ); + + const handleResolutionChanged = useCallback( + (newValue: Key) => { + if (!isResolution(newValue)) { + throw new TypeError("Invalid resolution"); + } + const resolution: Resolution = newValue; + setResolution(resolution).catch((error: unknown) => { + logger.error("Failed to update resolution", error); + }); + setLookback(RESOLUTION_TO_LOOKBACK[resolution]).catch( + (error: unknown) => { + logger.error("Failed to update lookback", error); + }, + ); + }, + [logger, setResolution, setLookback], + ); + + return ( + <> + Date: Thu, 2 Oct 2025 20:05:00 +0200 Subject: [PATCH 5/7] update query --- .../src/components/PriceFeed/Chart/chart.module.scss | 2 +- apps/insights/src/services/clickhouse.ts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/insights/src/components/PriceFeed/Chart/chart.module.scss b/apps/insights/src/components/PriceFeed/Chart/chart.module.scss index 8c5b1d620e..fd5496f5cb 100644 --- a/apps/insights/src/components/PriceFeed/Chart/chart.module.scss +++ b/apps/insights/src/components/PriceFeed/Chart/chart.module.scss @@ -2,7 +2,7 @@ .chart { --chart-background-light: #{theme.pallette-color("white")}; - --chart-background-dark: #{theme.pallette-color("steel", 900)}; + --chart-background-dark: #{theme.pallette-color("steel", 950)}; --border-light: #{theme.pallette-color("stone", 100)}; --border-dark: #{theme.pallette-color("steel", 900)}; --muted-light: #{theme.pallette-color("stone", 700)}; diff --git a/apps/insights/src/services/clickhouse.ts b/apps/insights/src/services/clickhouse.ts index f3bb10b9f2..8ae3546bd4 100644 --- a/apps/insights/src/services/clickhouse.ts +++ b/apps/insights/src/services/clickhouse.ts @@ -359,11 +359,9 @@ export const getHistoricalPrices = async ({ symbol, from, to, + publisher, }; - const publisherClause = publisher - ? "AND publisher = {publisher: String}" - : ""; const clusterClause = cluster === "pythtest" ? "cluster IN {clusters: Array(String)}" @@ -392,13 +390,12 @@ export const getHistoricalPrices = async ({ FROM prices PREWHERE ${clusterClause} + AND publisher = {publisher: String} AND symbol = {symbol: String} - ${publisherClause} AND version = 2 WHERE publishTime >= toDateTime({from: UInt32}) AND publishTime < toDateTime({to: UInt32}) - /* AND toDate(time) BETWEEN toDate(toDateTime({from: UInt32})) AND toDate(toDateTime({to: UInt32})) */ GROUP BY timestamp ORDER BY timestamp ASC `, From 11c89c5b67e7d84da649d37bb4e64cdfd5d90e05 Mon Sep 17 00:00:00 2001 From: Filip Hallqvist Date: Mon, 6 Oct 2025 13:52:21 +0200 Subject: [PATCH 6/7] PR feedback --- .../src/app/historical-prices/route.ts | 16 ++--- .../PriceFeed/Chart/chart-toolbar.tsx | 72 ++++++++----------- .../src/components/PriceFeed/Chart/chart.tsx | 65 +++++++++-------- .../PriceFeed/Chart/use-chart-toolbar.tsx | 31 +++++--- apps/insights/src/services/clickhouse.ts | 20 +----- 5 files changed, 90 insertions(+), 114 deletions(-) diff --git a/apps/insights/src/app/historical-prices/route.ts b/apps/insights/src/app/historical-prices/route.ts index 2b7ea12276..f84cba43f7 100644 --- a/apps/insights/src/app/historical-prices/route.ts +++ b/apps/insights/src/app/historical-prices/route.ts @@ -9,7 +9,6 @@ const queryParamsSchema = z.object({ .string() .nullable() .transform((value) => value ?? undefined), - cluster: z.enum(["pythnet", "pythtest"]), from: z.string().transform(Number), to: z.string().transform(Number), resolution: z.enum(["1s", "1m", "5m", "1H", "1D"]).transform((value) => { @@ -48,11 +47,9 @@ export async function GET(req: NextRequest) { }); } - const { symbol, publisher, cluster, from, to, resolution } = parsed.data; + const { symbol, publisher, from, to, resolution } = parsed.data; - try { - checkMaxDataPointsInvariant(from, to, resolution); - } catch { + if (getNumDataPoints(to, from, resolution) > MAX_DATA_POINTS) { return new Response("Unsupported resolution for date range", { status: 400, }); @@ -63,7 +60,6 @@ export async function GET(req: NextRequest) { from, to, publisher, - cluster, resolution, }); @@ -71,9 +67,9 @@ export async function GET(req: NextRequest) { } const MAX_DATA_POINTS = 3000; -function checkMaxDataPointsInvariant( - from: number, +function getNumDataPoints( to: number, + from: number, resolution: "1 SECOND" | "1 MINUTE" | "5 MINUTE" | "1 HOUR" | "1 DAY", ) { let diff = to - from; @@ -96,7 +92,5 @@ function checkMaxDataPointsInvariant( } } - if (diff > MAX_DATA_POINTS) { - throw new Error("Unsupported resolution for date range"); - } + return diff; } diff --git a/apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx b/apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx index 1199afdc1d..deb7e64efc 100644 --- a/apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx +++ b/apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx @@ -5,13 +5,13 @@ import { useLogger } from "@pythnetwork/component-library/useLogger"; import { useCallback } from "react"; import type { Key } from "react-aria"; -import type { Lookback, Resolution } from "./use-chart-toolbar"; +import type { QuickSelectWindow, Resolution } from "./use-chart-toolbar"; import { - LOOKBACK_TO_RESOLUTION, - LOOKBACKS, - RESOLUTION_TO_LOOKBACK, + QUICK_SELECT_WINDOW_TO_RESOLUTION, + QUICK_SELECT_WINDOWS, + RESOLUTION_TO_QUICK_SELECT_WINDOW, RESOLUTIONS, - useChartLookback, + useChartQuickSelectWindow, useChartResolution, } from "./use-chart-toolbar"; @@ -19,43 +19,38 @@ const ENABLE_RESOLUTION_SELECTOR = false; export const ChartToolbar = () => { const logger = useLogger(); - const [lookback, setLookback] = useChartLookback(); + const [quickSelectWindow, setQuickSelectWindow] = useChartQuickSelectWindow(); const [resolution, setResolution] = useChartResolution(); - const handleLookbackChange = useCallback( - (newValue: Key) => { - if (!isLookback(newValue)) { - throw new TypeError("Invalid lookback"); - } - const lookback: Lookback = newValue; - setLookback(lookback).catch((error: unknown) => { - logger.error("Failed to update lookback", error); + const handleResolutionChanged = useCallback( + (resolution: Resolution) => { + setResolution(resolution).catch((error: unknown) => { + logger.error("Failed to update resolution", error); }); - setResolution(LOOKBACK_TO_RESOLUTION[lookback]).catch( + setQuickSelectWindow(RESOLUTION_TO_QUICK_SELECT_WINDOW[resolution]).catch( (error: unknown) => { - logger.error("Failed to update resolution", error); + logger.error("Failed to update quick select window", error); }, ); }, - [logger, setLookback, setResolution], + [logger, setResolution, setQuickSelectWindow], ); - const handleResolutionChanged = useCallback( - (newValue: Key) => { - if (!isResolution(newValue)) { - throw new TypeError("Invalid resolution"); + const handleQuickSelectWindowChange = useCallback( + (quickSelectWindow: Key) => { + if (!isQuickSelectWindow(quickSelectWindow)) { + throw new TypeError("Invalid quick select window"); } - const resolution: Resolution = newValue; - setResolution(resolution).catch((error: unknown) => { - logger.error("Failed to update resolution", error); + setQuickSelectWindow(quickSelectWindow).catch((error: unknown) => { + logger.error("Failed to update quick select window", error); }); - setLookback(RESOLUTION_TO_LOOKBACK[resolution]).catch( + setResolution(QUICK_SELECT_WINDOW_TO_RESOLUTION[quickSelectWindow]).catch( (error: unknown) => { - logger.error("Failed to update lookback", error); + logger.error("Failed to update resolution", error); }, ); }, - [logger, setResolution, setLookback], + [logger, setQuickSelectWindow, setResolution], ); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -78,25 +73,18 @@ export const ChartToolbar = () => { variant="outline" /> ({ + id: quickSelectWindow, + children: quickSelectWindow, + }))} /> ); }; -function isLookback(value: Key): value is Lookback { - return LOOKBACKS.includes(value as Lookback); -} - -function isResolution(value: Key): value is Resolution { - return RESOLUTIONS.includes(value as Resolution); +function isQuickSelectWindow(value: Key): value is QuickSelectWindow { + return QUICK_SELECT_WINDOWS.includes(value as QuickSelectWindow); } diff --git a/apps/insights/src/components/PriceFeed/Chart/chart.tsx b/apps/insights/src/components/PriceFeed/Chart/chart.tsx index dc07a80de4..9f365ce497 100644 --- a/apps/insights/src/components/PriceFeed/Chart/chart.tsx +++ b/apps/insights/src/components/PriceFeed/Chart/chart.tsx @@ -13,7 +13,6 @@ import type { ISeriesApi, LineData, UTCTimestamp, - WhitespaceData, } from "lightweight-charts"; import { AreaSeries, @@ -28,8 +27,8 @@ import { z } from "zod"; import styles from "./chart.module.scss"; import { - lookbackToMilliseconds, - useChartLookback, + quickSelectWindowToMilliseconds, + useChartQuickSelectWindow, useChartResolution, } from "./use-chart-toolbar"; import { useLivePriceData } from "../../../hooks/use-live-price-data"; @@ -62,19 +61,14 @@ const useChart = (symbol: string, feedId: string) => { const useChartElem = (symbol: string, feedId: string) => { const logger = useLogger(); - const [lookback] = useChartLookback(); + const [quickSelectWindow] = useChartQuickSelectWindow(); const [resolution] = useChartResolution(); const chartContainerRef = useRef(null); const chartRef = useRef(undefined); const isBackfilling = useRef(false); const priceFormatter = usePriceFormatter(); - const resolutionRef = useRef(resolution); const abortControllerRef = useRef(undefined); - useEffect(() => { - resolutionRef.current = resolution; - }, [resolution]); - const { current: livePriceData } = useLivePriceData(Cluster.Pythnet, feedId); const didResetVisibleRange = useRef(false); @@ -154,31 +148,33 @@ const useChartElem = (symbol: string, feedId: string) => { }); fetch(url, { signal: abortControllerRef.current.signal }) - .then(async (data) => historicalDataSchema.parse(await data.json())) - .then((data) => { + .then((rawData) => rawData.json()) + .then((jsonData) => { if (!chartRef.current) { return; } + const data = historicalDataSchema.parse(jsonData); + // Get the current historical price data - const currentHistoricalPriceData = chartRef.current.price - .data() - .filter((d) => isLineData(d)); + // Note that .data() returns (WhitespaceData | LineData)[], hence the type cast + const currentHistoricalPriceData = + chartRef.current.price.data() as LineData[]; const currentHistoricalConfidenceHighData = - chartRef.current.confidenceHigh.data().filter((d) => isLineData(d)); + chartRef.current.confidenceHigh.data() as LineData[]; const currentHistoricalConfidenceLowData = - chartRef.current.confidenceLow.data().filter((d) => isLineData(d)); + chartRef.current.confidenceLow.data() as LineData[]; const newHistoricalPriceData = data.map((d) => ({ - time: Number(d.timestamp) as UTCTimestamp, + time: d.time, value: d.price, })); const newHistoricalConfidenceHighData = data.map((d) => ({ - time: Number(d.timestamp) as UTCTimestamp, + time: d.time, value: d.price + d.confidence, })); const newHistoricalConfidenceLowData = data.map((d) => ({ - time: Number(d.timestamp) as UTCTimestamp, + time: d.time, value: d.price - d.confidence, })); @@ -273,7 +269,7 @@ const useChartElem = (symbol: string, feedId: string) => { const newToMs = firstMs; const newFromMs = startOfResolution( new Date(newToMs - visibleRangeMs), - resolutionRef.current, + resolution, ); // When we're getting close to the earliest data, we need to backfill more @@ -281,7 +277,7 @@ const useChartElem = (symbol: string, feedId: string) => { fetchHistoricalData({ from: newFromMs / 1000, to: newToMs / 1000, - resolution: resolutionRef.current, + resolution, }); } }); @@ -306,7 +302,9 @@ const useChartElem = (symbol: string, feedId: string) => { const now = new Date(); const to = startOfResolution(now, resolution); const from = startOfResolution( - new Date(now.getTime() - lookbackToMilliseconds(lookback)), + new Date( + now.getTime() - quickSelectWindowToMilliseconds(quickSelectWindow), + ), resolution, ); @@ -326,7 +324,7 @@ const useChartElem = (symbol: string, feedId: string) => { to: to / 1000, resolution, }); - }, [lookback, resolution, fetchHistoricalData]); + }, [quickSelectWindow, resolution, fetchHistoricalData]); return { chartRef, chartContainerRef }; }; @@ -340,11 +338,17 @@ type ChartRefContents = { }; const historicalDataSchema = z.array( - z.strictObject({ - timestamp: z.number().transform(BigInt), - price: z.number(), - confidence: z.number(), - }), + z + .strictObject({ + timestamp: z.number(), + price: z.number(), + confidence: z.number(), + }) + .transform((d) => ({ + time: Number(d.timestamp) as UTCTimestamp, + price: d.price, + confidence: d.confidence, + })), ); const priceFormat = { type: "price", @@ -443,17 +447,12 @@ const getColors = (container: HTMLDivElement, resolvedTheme: string) => { }; }; -function isLineData(data: LineData | WhitespaceData): data is LineData { - return "time" in data && "value" in data; -} - /** * Merge (and sort) two arrays of line data, deduplicating by time */ export function mergeData(as: LineData[], bs: LineData[]) { const unique = new Map(); - // TODO fhqvst Can optimize with while's for (const a of as) { unique.set(a.time as number, a); } diff --git a/apps/insights/src/components/PriceFeed/Chart/use-chart-toolbar.tsx b/apps/insights/src/components/PriceFeed/Chart/use-chart-toolbar.tsx index 22b4c1bf01..6c862427c1 100644 --- a/apps/insights/src/components/PriceFeed/Chart/use-chart-toolbar.tsx +++ b/apps/insights/src/components/PriceFeed/Chart/use-chart-toolbar.tsx @@ -3,13 +3,13 @@ import { parseAsStringLiteral, useQueryState } from "nuqs"; export const RESOLUTIONS = ["1s", "1m", "5m", "1H", "1D"] as const; export type Resolution = (typeof RESOLUTIONS)[number]; -export const LOOKBACKS = ["1m", "1H", "1D", "1W", "1M"] as const; -export type Lookback = (typeof LOOKBACKS)[number]; +export const QUICK_SELECT_WINDOWS = ["1m", "1H", "1D", "1W", "1M"] as const; +export type QuickSelectWindow = (typeof QUICK_SELECT_WINDOWS)[number]; -export function useChartLookback() { +export function useChartQuickSelectWindow() { return useQueryState( - "lookback", - parseAsStringLiteral(LOOKBACKS).withDefault("1m"), + "quickSelectWindow", + parseAsStringLiteral(QUICK_SELECT_WINDOWS).withDefault("1m"), ); } @@ -20,9 +20,14 @@ export function useChartResolution() { ); } -// TODO fhqvst Clean this up - it's confusing -export function lookbackToMilliseconds(lookback: Lookback): number { - switch (lookback) { +/** + * Converts a quick select window string (e.g., "1m", "1H", "1D") to its equivalent duration in milliseconds. + * Used to determine the time range for chart data based on user selection. + */ +export function quickSelectWindowToMilliseconds( + quickSelectWindow: QuickSelectWindow, +): number { + switch (quickSelectWindow) { case "1m": { return 60_000; } @@ -41,7 +46,10 @@ export function lookbackToMilliseconds(lookback: Lookback): number { } } -export const RESOLUTION_TO_LOOKBACK: Record = { +export const RESOLUTION_TO_QUICK_SELECT_WINDOW: Record< + Resolution, + QuickSelectWindow +> = { "1s": "1m", "1m": "1H", "5m": "1D", @@ -49,7 +57,10 @@ export const RESOLUTION_TO_LOOKBACK: Record = { "1D": "1M", }; -export const LOOKBACK_TO_RESOLUTION: Record = { +export const QUICK_SELECT_WINDOW_TO_RESOLUTION: Record< + QuickSelectWindow, + Resolution +> = { "1m": "1s", "1H": "1m", "1D": "5m", diff --git a/apps/insights/src/services/clickhouse.ts b/apps/insights/src/services/clickhouse.ts index 8ae3546bd4..f2834a6a90 100644 --- a/apps/insights/src/services/clickhouse.ts +++ b/apps/insights/src/services/clickhouse.ts @@ -345,37 +345,21 @@ export const getHistoricalPrices = async ({ to, publisher = "", resolution = "1 MINUTE", - cluster = "pythnet", }: { symbol: string; from: number; to: number; publisher: string | undefined; - // TODO fhqvst Should we prevent fetching second-resolution data? resolution?: Resolution; - cluster?: "pythnet" | "pythtest"; }) => { const queryParams: Record = { symbol, from, to, publisher, + cluster: "pythnet", }; - const clusterClause = - cluster === "pythtest" - ? "cluster IN {clusters: Array(String)}" - : "cluster = {cluster: String}"; - - if (publisher) { - queryParams.publisher = publisher; - } - if (cluster === "pythtest") { - queryParams.clusters = ["pythnet", "pythtest-conformance"]; - } else { - queryParams.cluster = "pythnet"; - } - return safeQuery( z.array( z.strictObject({ @@ -389,7 +373,7 @@ export const getHistoricalPrices = async ({ SELECT toUnixTimestamp(toStartOfInterval(publishTime, INTERVAL ${resolution})) AS timestamp, avg(price) AS price, avg(confidence) AS confidence FROM prices PREWHERE - ${clusterClause} + cluster = {cluster: String} AND publisher = {publisher: String} AND symbol = {symbol: String} AND version = 2 From dde6add7417f7bd86530e280dce624a448dd8c21 Mon Sep 17 00:00:00 2001 From: Filip Hallqvist Date: Tue, 7 Oct 2025 16:14:06 +0200 Subject: [PATCH 7/7] PR feedback --- .../src/app/historical-prices/route.ts | 40 +++++++------------ .../src/components/PriceFeed/Chart/chart.tsx | 3 +- .../PriceFeed/Chart/use-chart-toolbar.tsx | 33 ++++++++------- 3 files changed, 32 insertions(+), 44 deletions(-) diff --git a/apps/insights/src/app/historical-prices/route.ts b/apps/insights/src/app/historical-prices/route.ts index f84cba43f7..868eb58517 100644 --- a/apps/insights/src/app/historical-prices/route.ts +++ b/apps/insights/src/app/historical-prices/route.ts @@ -67,30 +67,18 @@ export async function GET(req: NextRequest) { } const MAX_DATA_POINTS = 3000; -function getNumDataPoints( - to: number, - from: number, - resolution: "1 SECOND" | "1 MINUTE" | "5 MINUTE" | "1 HOUR" | "1 DAY", -) { - let diff = to - from; - switch (resolution) { - case "1 MINUTE": { - diff = diff / 60; - break; - } - case "5 MINUTE": { - diff = diff / 60 / 5; - break; - } - case "1 HOUR": { - diff = diff / 3600; - break; - } - case "1 DAY": { - diff = diff / 86_400; - break; - } - } - return diff; -} +type Resolution = "1 SECOND" | "1 MINUTE" | "5 MINUTE" | "1 HOUR" | "1 DAY"; +const ONE_MINUTE_IN_SECONDS = 60; +const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS; + +const SECONDS_IN_ONE_PERIOD: Record = { + "1 SECOND": 1, + "1 MINUTE": ONE_MINUTE_IN_SECONDS, + "5 MINUTE": 5 * ONE_MINUTE_IN_SECONDS, + "1 HOUR": ONE_HOUR_IN_SECONDS, + "1 DAY": 24 * ONE_HOUR_IN_SECONDS, +}; + +const getNumDataPoints = (from: number, to: number, resolution: Resolution) => + (to - from) / SECONDS_IN_ONE_PERIOD[resolution]; diff --git a/apps/insights/src/components/PriceFeed/Chart/chart.tsx b/apps/insights/src/components/PriceFeed/Chart/chart.tsx index 9f365ce497..3e85671b86 100644 --- a/apps/insights/src/components/PriceFeed/Chart/chart.tsx +++ b/apps/insights/src/components/PriceFeed/Chart/chart.tsx @@ -157,7 +157,8 @@ const useChartElem = (symbol: string, feedId: string) => { const data = historicalDataSchema.parse(jsonData); // Get the current historical price data - // Note that .data() returns (WhitespaceData | LineData)[], hence the type cast + // Note that .data() returns (WhitespaceData | LineData)[], hence the type cast. + // We never populate the chart with WhitespaceData, so the type cast is safe. const currentHistoricalPriceData = chartRef.current.price.data() as LineData[]; const currentHistoricalConfidenceHighData = diff --git a/apps/insights/src/components/PriceFeed/Chart/use-chart-toolbar.tsx b/apps/insights/src/components/PriceFeed/Chart/use-chart-toolbar.tsx index 6c862427c1..1dc063b888 100644 --- a/apps/insights/src/components/PriceFeed/Chart/use-chart-toolbar.tsx +++ b/apps/insights/src/components/PriceFeed/Chart/use-chart-toolbar.tsx @@ -20,6 +20,21 @@ export function useChartResolution() { ); } +const ONE_SECOND_IN_MS = 1000; +const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS; +const ONE_HOUR_IN_MS = 60 * ONE_MINUTE_IN_MS; +const ONE_DAY_IN_MS = 24 * ONE_HOUR_IN_MS; +const ONE_WEEK_IN_MS = 7 * ONE_DAY_IN_MS; +const ONE_MONTH_IN_MS = 30 * ONE_DAY_IN_MS; + +const QUICK_SELECT_WINDOW_TO_MS: Record = { + "1m": ONE_MINUTE_IN_MS, + "1H": ONE_HOUR_IN_MS, + "1D": ONE_DAY_IN_MS, + "1W": ONE_WEEK_IN_MS, + "1M": ONE_MONTH_IN_MS, +}; + /** * Converts a quick select window string (e.g., "1m", "1H", "1D") to its equivalent duration in milliseconds. * Used to determine the time range for chart data based on user selection. @@ -27,23 +42,7 @@ export function useChartResolution() { export function quickSelectWindowToMilliseconds( quickSelectWindow: QuickSelectWindow, ): number { - switch (quickSelectWindow) { - case "1m": { - return 60_000; - } - case "1H": { - return 3_600_000; - } - case "1D": { - return 86_400_000; - } - case "1W": { - return 604_800_000; - } - case "1M": { - return 2_629_746_000; - } - } + return QUICK_SELECT_WINDOW_TO_MS[quickSelectWindow]; } export const RESOLUTION_TO_QUICK_SELECT_WINDOW: Record<