Skip to content

Commit 3c3af57

Browse files
committed
feat: reimplemented chart data fetching
1 parent cd8e3ff commit 3c3af57

File tree

10 files changed

+282
-28
lines changed

10 files changed

+282
-28
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { NextRequest } from "next/server";
2+
import { z } from "zod";
3+
4+
import { getHistory } from "../../../../services/clickhouse";
5+
6+
const queryParamsSchema = z.object({
7+
symbol: z.string(),
8+
range: z.enum(["1H", "1D", "1W", "1M"]),
9+
cluster: z.enum(["pythnet", "pythtest-conformance"]),
10+
from: z.string().transform(Number),
11+
until: z.string().transform(Number),
12+
});
13+
14+
export async function GET(req: NextRequest) {
15+
const searchParams = req.nextUrl.searchParams;
16+
17+
// Parse and validate query parameters
18+
const symbol = searchParams.get("symbol");
19+
const range = searchParams.get("range");
20+
const cluster = searchParams.get("cluster");
21+
const from = searchParams.get("from");
22+
const until = searchParams.get("until");
23+
24+
if (!symbol || !range || !cluster) {
25+
return new Response(
26+
"Missing required parameters. Must provide `symbol`, `range`, and `cluster`",
27+
{ status: 400 }
28+
);
29+
}
30+
31+
try {
32+
// Validate parameters using the schema
33+
const validatedParams = queryParamsSchema.parse({
34+
symbol,
35+
range,
36+
cluster,
37+
from,
38+
until,
39+
});
40+
41+
const data = await getHistory({
42+
symbol: validatedParams.symbol,
43+
range: validatedParams.range,
44+
cluster: validatedParams.cluster,
45+
from: validatedParams.from,
46+
until: validatedParams.until,
47+
});
48+
49+
return Response.json(data);
50+
} catch (error) {
51+
if (error instanceof z.ZodError) {
52+
return new Response(
53+
`Invalid parameters: ${error.errors.map(e => e.message).join(", ")}`,
54+
{ status: 400 }
55+
);
56+
}
57+
58+
console.error("Error fetching history data:", error);
59+
return new Response(
60+
"Internal server error",
61+
{ status: 500 }
62+
);
63+
}
64+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { ChartPageLoading as default } from "../../../../components/PriceFeed/chart-page";
1+
export { ChartPageLoading as default } from "../../../../components/PriceFeed/Chart/chart-page";
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export { ChartPage as default } from "../../../../components/PriceFeed/chart-page";
1+
export { ChartPage as default } from "../../../../components/PriceFeed/Chart/chart-page";
22

33
export const revalidate = 3600;

apps/insights/src/components/PriceFeed/chart-page.tsx renamed to apps/insights/src/components/PriceFeed/Chart/chart-page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { Spinner } from "@pythnetwork/component-library/Spinner";
55

66
import { Chart } from "./chart";
77
import styles from "./chart-page.module.scss";
8-
import { getFeed } from "./get-feed";
8+
import { getFeed } from "../get-feed";
9+
import { ChartToolbar } from "./chart-toolbar";
910

1011
type Props = {
1112
params: Promise<{
@@ -26,7 +27,7 @@ type ChartPageImplProps =
2627
});
2728

2829
const ChartPageImpl = (props: ChartPageImplProps) => (
29-
<Card title="Chart" className={styles.chartCard}>
30+
<Card title="Chart" className={styles.chartCard} toolbar={<ChartToolbar />}>
3031
<div className={styles.chart}>
3132
{props.isLoading ? (
3233
<div className={styles.spinnerContainer}>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use client'
2+
import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
3+
import { useLogger } from "@pythnetwork/component-library/useLogger";
4+
import { parseAsStringEnum, useQueryState } from 'nuqs';
5+
import { useCallback } from 'react';
6+
7+
export enum Interval {
8+
Live,
9+
OneHour,
10+
OneDay,
11+
OneWeek,
12+
OneMonth,
13+
}
14+
15+
export const INTERVAL_NAMES = {
16+
[Interval.Live]: "Live",
17+
[Interval.OneHour]: "1H",
18+
[Interval.OneDay]: "1D",
19+
[Interval.OneWeek]: "1W",
20+
[Interval.OneMonth]: "1M",
21+
} as const;
22+
23+
export const toInterval = (name: (typeof INTERVAL_NAMES)[keyof typeof INTERVAL_NAMES]): Interval => {
24+
switch (name) {
25+
case "Live": {
26+
return Interval.Live;
27+
}
28+
case "1H": {
29+
return Interval.OneHour;
30+
}
31+
case "1D": {
32+
return Interval.OneDay;
33+
}
34+
case "1W": {
35+
return Interval.OneWeek;
36+
}
37+
case "1M": {
38+
return Interval.OneMonth;
39+
}
40+
}
41+
};
42+
export const ChartToolbar = () => {
43+
const logger = useLogger();
44+
const [interval, setInterval] = useQueryState(
45+
"interval",
46+
parseAsStringEnum(Object.values(INTERVAL_NAMES)).withDefault("Live"),
47+
);
48+
49+
const handleSelectionChange = useCallback((newValue: Interval) => {
50+
setInterval(INTERVAL_NAMES[newValue]).catch((error: unknown) => {
51+
logger.error("Failed to update interval", error);
52+
});
53+
}, [logger, setInterval]);
54+
55+
return (
56+
<SingleToggleGroup
57+
selectedKey={toInterval(interval)}
58+
// @ts-expect-error - wrong param type
59+
onSelectionChange={handleSelectionChange}
60+
rounded
61+
items={[
62+
{ id: Interval.Live, children: INTERVAL_NAMES[Interval.Live] },
63+
{ id: Interval.OneHour, children: INTERVAL_NAMES[Interval.OneHour] },
64+
{ id: Interval.OneDay, children: INTERVAL_NAMES[Interval.OneDay] },
65+
{ id: Interval.OneWeek, children: INTERVAL_NAMES[Interval.OneWeek] },
66+
{ id: Interval.OneMonth, children: INTERVAL_NAMES[Interval.OneMonth] },
67+
]}
68+
/>
69+
);
70+
};

apps/insights/src/components/PriceFeed/chart.tsx renamed to apps/insights/src/components/PriceFeed/Chart/chart.tsx

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import { useEffect, useRef, useCallback } from "react";
1010
import { z } from "zod";
1111

1212
import styles from "./chart.module.scss";
13-
import { useLivePriceData } from "../../hooks/use-live-price-data";
14-
import { usePriceFormatter } from "../../hooks/use-price-formatter";
15-
import { Cluster } from "../../services/pyth";
13+
import { useLivePriceData } from "../../../hooks/use-live-price-data";
14+
import { usePriceFormatter } from "../../../hooks/use-price-formatter";
15+
import { Cluster } from "../../../services/pyth";
16+
import { parseAsStringEnum, useQueryState } from 'nuqs';
17+
import { INTERVAL_NAMES } from './chart-toolbar';
1618

1719
type Props = {
1820
symbol: string;
@@ -37,6 +39,30 @@ const useChart = (symbol: string, feedId: string) => {
3739
useChartColors(chartContainerRef, chartRef);
3840
return chartContainerRef;
3941
};
42+
const historySchema = z.array(
43+
z.object({
44+
timestamp: z.number(),
45+
openPrice: z.number(),
46+
lowPrice: z.number(),
47+
closePrice: z.number(),
48+
highPrice: z.number(),
49+
avgPrice: z.number(),
50+
avgConfidence: z.number(),
51+
avgEmaPrice: z.number(),
52+
avgEmaConfidence: z.number(),
53+
startSlot: z.string(),
54+
endSlot: z.string(),
55+
}));
56+
const fetchHistory = async ({ symbol, range, cluster, from, until }: { symbol: string, range: string, cluster: string, from: bigint, until: bigint }) => {
57+
const url = new URL("/api/pyth/get-history", globalThis.location.origin);
58+
url.searchParams.set("symbol", symbol);
59+
url.searchParams.set("range", range);
60+
url.searchParams.set("cluster", cluster);
61+
url.searchParams.set("from", from.toString());
62+
url.searchParams.set("until", until.toString());
63+
console.log("fetching history", {from: new Date(Number(from) * 1000), until: new Date(Number(until) * 1000)}, url.toString());
64+
return fetch(url).then(async (data) => historySchema.parse(await data.json()));
65+
}
4066

4167
const useChartElem = (symbol: string, feedId: string) => {
4268
const logger = useLogger();
@@ -46,15 +72,17 @@ const useChartElem = (symbol: string, feedId: string) => {
4672
const earliestDateRef = useRef<bigint | undefined>(undefined);
4773
const isBackfilling = useRef(false);
4874
const priceFormatter = usePriceFormatter();
49-
75+
const [interval] = useQueryState(
76+
"interval",
77+
parseAsStringEnum(Object.values(INTERVAL_NAMES)).withDefault("Live"),
78+
);
5079
const backfillData = useCallback(() => {
5180
if (!isBackfilling.current && earliestDateRef.current) {
5281
isBackfilling.current = true;
53-
const url = new URL("/historical-prices", globalThis.location.origin);
54-
url.searchParams.set("symbol", symbol);
55-
url.searchParams.set("until", earliestDateRef.current.toString());
56-
fetch(url)
57-
.then(async (data) => historicalDataSchema.parse(await data.json()))
82+
// seconds to date
83+
console.log("backfilling", new Date(Number(earliestDateRef.current) * 1000));
84+
const range = interval === "Live" ? "1H" : interval;
85+
fetchHistory({ symbol, range, cluster: "pythnet", from: earliestDateRef.current - 100n, until: earliestDateRef.current -2n })
5886
.then((data) => {
5987
const firstPoint = data[0];
6088
if (firstPoint) {
@@ -65,19 +93,30 @@ const useChartElem = (symbol: string, feedId: string) => {
6593
chartRef.current.resolution === Resolution.Tick
6694
) {
6795
const convertedData = data.map(
68-
({ timestamp, price, confidence }) => ({
69-
time: getLocalTimestamp(new Date(timestamp * 1000)),
70-
price,
71-
confidence,
96+
({ timestamp, avgPrice, avgConfidence }) => ({
97+
time: getLocalTimestamp(new Date(timestamp*1000)),
98+
price: avgPrice,
99+
confidence: avgConfidence,
72100
}),
73101
);
102+
console.log("convertedData",
103+
{current: chartRef?.current?.price.data().map(({ time, value }) => ({
104+
time: new Date(time*1000),
105+
value,
106+
})),
107+
converted: convertedData.map(({ time, price }) => ({
108+
time: new Date(time*1000),
109+
value: price,
110+
}))
111+
});
74112
chartRef.current.price.setData([
75113
...convertedData.map(({ time, price }) => ({
76114
time,
77115
value: price,
78116
})),
79117
...chartRef.current.price.data(),
80118
]);
119+
81120
chartRef.current.confidenceHigh.setData([
82121
...convertedData.map(({ time, price, confidence }) => ({
83122
time,
@@ -194,14 +233,6 @@ type ChartRefContents = {
194233
}
195234
);
196235

197-
const historicalDataSchema = z.array(
198-
z.strictObject({
199-
timestamp: z.number(),
200-
price: z.number(),
201-
confidence: z.number(),
202-
}),
203-
);
204-
205236
const priceFormat = {
206237
type: "price",
207238
precision: 5,

apps/insights/src/services/clickhouse.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ const _getYesterdaysPrices = async (symbols: string[]) =>
183183
},
184184
);
185185

186+
187+
186188
const _getPublisherRankingHistory = async ({
187189
cluster,
188190
key,
@@ -369,13 +371,99 @@ export const getHistoricalPrices = async ({
369371
},
370372
);
371373

374+
375+
// Main history function
376+
export const getHistory = async ({
377+
symbol,
378+
range,
379+
cluster,
380+
from,
381+
until,
382+
}: {
383+
symbol: string;
384+
range: "1H" | "1D" | "1W" | "1M";
385+
cluster: "pythnet" | "pythtest-conformance";
386+
from: number;
387+
until: number;
388+
}) => {
389+
390+
// Calculate interval parameters based on range
391+
const start = (range === "1H" || range === "1D") ? "1" : (range === "1W") ? "7" : "30";
392+
const range_unit = range === "1H" ? "HOUR" : "DAY";
393+
const interval_number = range === "1H" ? 5 : 1;
394+
const interval_unit = range === "1H" ? "SECOND" : (range === "1D") ? "MINUTE" : "HOUR";
395+
396+
let additional_cluster_clause = "";
397+
if (cluster === "pythtest-conformance") {
398+
additional_cluster_clause = " OR (cluster = 'pythtest')";
399+
}
400+
401+
const query = `
402+
SELECT
403+
toUnixTimestamp(toStartOfInterval(publishTime, INTERVAL {interval_number: UInt32} ${interval_unit})) AS timestamp,
404+
argMin(price, slot) AS openPrice,
405+
min(price) AS lowPrice,
406+
argMax(price, slot) AS closePrice,
407+
max(price) AS highPrice,
408+
avg(price) AS avgPrice,
409+
avg(confidence) AS avgConfidence,
410+
avg(emaPrice) AS avgEmaPrice,
411+
avg(emaConfidence) AS avgEmaConfidence,
412+
min(slot) AS startSlot,
413+
max(slot) AS endSlot
414+
FROM prices
415+
FINAL
416+
WHERE ((symbol = {symbol: String}) OR (symbol = {base_quote_symbol: String}))
417+
AND ((cluster = {cluster: String})${additional_cluster_clause})
418+
AND (version = 2)
419+
AND (publishTime > fromUnixTimestamp(toInt64({from: String})))
420+
AND (publishTime <= fromUnixTimestamp(toInt64({until: String})))
421+
AND (time > (fromUnixTimestamp(toInt64({from: String})) - INTERVAL 5 SECOND))
422+
AND (time <= (fromUnixTimestamp(toInt64({until: String})) + INTERVAL 5 SECOND))
423+
AND (publisher = {publisher: String})
424+
AND (status = 1)
425+
GROUP BY timestamp
426+
ORDER BY timestamp ASC
427+
SETTINGS do_not_merge_across_partitions_select_final=1
428+
`;
429+
console.log(query)
430+
const data = await safeQuery(z.array(
431+
z.object({
432+
timestamp: z.number(),
433+
openPrice: z.number(),
434+
lowPrice: z.number(),
435+
closePrice: z.number(),
436+
highPrice: z.number(),
437+
avgPrice: z.number(),
438+
avgConfidence: z.number(),
439+
avgEmaPrice: z.number(),
440+
avgEmaConfidence: z.number(),
441+
startSlot: z.string(),
442+
endSlot: z.string(),
443+
})), {
444+
query,
445+
query_params: {
446+
symbol,
447+
cluster,
448+
publisher: "",
449+
base_quote_symbol: symbol.split(".")[-1],
450+
from,
451+
until,
452+
start,
453+
interval_number,
454+
},
455+
});
456+
console.log("from", new Date(from * 1000), from, "until", new Date(until * 1000), until, data[0], data[data.length - 1]);
457+
458+
return data;
459+
};
460+
372461
const safeQuery = async <Output, Def extends ZodTypeDef, Input>(
373462
schema: ZodSchema<Output, Def, Input>,
374463
query: Omit<Parameters<typeof client.query>[0], "format">,
375464
) => {
376465
const rows = await client.query({ ...query, format: "JSON" });
377466
const result = await rows.json();
378-
379467
return schema.parse(result.data);
380468
};
381469

0 commit comments

Comments
 (0)