Skip to content

Commit f954684

Browse files
authored
Merge pull request #2301 from pyth-network/cprussin/insights-hub-price-chart
feat(insights): initial version of price chart
2 parents c99f0f0 + e2a7831 commit f954684

File tree

25 files changed

+680
-230
lines changed

25 files changed

+680
-230
lines changed

apps/insights/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"clsx": "catalog:",
3636
"cryptocurrency-icons": "catalog:",
3737
"dnum": "catalog:",
38+
"lightweight-charts": "catalog:",
3839
"motion": "catalog:",
3940
"next": "catalog:",
4041
"next-themes": "catalog:",
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { NextRequest } from "next/server";
2+
3+
import { getHistoricalPrices } from "../../services/clickhouse";
4+
5+
export async function GET(req: NextRequest) {
6+
const symbol = req.nextUrl.searchParams.get("symbol");
7+
const until = req.nextUrl.searchParams.get("until");
8+
if (symbol && until) {
9+
const res = await getHistoricalPrices(decodeURIComponent(symbol), until);
10+
return Response.json(res);
11+
} else {
12+
return new Response("Must provide `symbol` and `until`", { status: 400 });
13+
}
14+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { Chart as default } from "../../../components/PriceFeed/chart";
1+
export { ChartPage as default } from "../../../components/PriceFeed/chart-page";

apps/insights/src/components/ChangePercent/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ComponentProps } from "react";
22

3+
import { omitKeys } from "../../omit-keys";
34
import { ChangeValue } from "../ChangeValue";
45
import { FormattedNumber } from "../FormattedNumber";
56

@@ -17,13 +18,13 @@ type PriceDifferenceProps = Omit<
1718
}
1819
);
1920

20-
export const ChangePercent = ({ ...props }: PriceDifferenceProps) =>
21+
export const ChangePercent = (props: PriceDifferenceProps) =>
2122
props.isLoading ? (
2223
<ChangeValue {...props} />
2324
) : (
2425
<ChangeValue
2526
direction={getDirection(props.currentValue, props.previousValue)}
26-
{...props}
27+
{...omitKeys(props, ["currentValue", "previousValue"])}
2728
>
2829
<FormattedNumber
2930
maximumFractionDigits={2}

apps/insights/src/components/LivePrices/index.tsx

Lines changed: 10 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,19 @@
11
"use client";
22

33
import { PlusMinus } from "@phosphor-icons/react/dist/ssr/PlusMinus";
4-
import { useLogger } from "@pythnetwork/app-logger";
54
import type { PriceData, PriceComponent } from "@pythnetwork/client";
65
import { Skeleton } from "@pythnetwork/component-library/Skeleton";
7-
import { useMap } from "@react-hookz/web";
8-
import { PublicKey } from "@solana/web3.js";
9-
import {
10-
type ComponentProps,
11-
type ReactNode,
12-
use,
13-
createContext,
14-
useEffect,
15-
useCallback,
16-
useState,
17-
useMemo,
18-
} from "react";
6+
import { type ReactNode, useMemo } from "react";
197
import { useNumberFormatter, useDateFormatter } from "react-aria";
208

219
import styles from "./index.module.scss";
2210
import {
23-
Cluster,
24-
subscribe,
25-
getAssetPricesFromAccounts,
26-
} from "../../services/pyth";
11+
useLivePriceComponent,
12+
useLivePriceData,
13+
} from "../../hooks/use-live-price-data";
2714

2815
export const SKELETON_WIDTH = 20;
2916

30-
const LivePricesContext = createContext<
31-
ReturnType<typeof usePriceData> | undefined
32-
>(undefined);
33-
34-
type LivePricesProviderProps = Omit<
35-
ComponentProps<typeof LivePricesContext>,
36-
"value"
37-
>;
38-
39-
export const LivePricesProvider = (props: LivePricesProviderProps) => {
40-
const priceData = usePriceData();
41-
42-
return <LivePricesContext value={priceData} {...props} />;
43-
};
44-
45-
export const useLivePrice = (feedKey: string) => {
46-
const { priceData, prevPriceData, addSubscription, removeSubscription } =
47-
useLivePrices();
48-
49-
useEffect(() => {
50-
addSubscription(feedKey);
51-
return () => {
52-
removeSubscription(feedKey);
53-
};
54-
}, [addSubscription, removeSubscription, feedKey]);
55-
56-
const current = priceData.get(feedKey);
57-
const prev = prevPriceData.get(feedKey);
58-
59-
return { current, prev };
60-
};
61-
62-
export const useLivePriceComponent = (
63-
feedKey: string,
64-
publisherKeyAsBase58: string,
65-
) => {
66-
const { current, prev } = useLivePrice(feedKey);
67-
const publisherKey = useMemo(
68-
() => new PublicKey(publisherKeyAsBase58),
69-
[publisherKeyAsBase58],
70-
);
71-
72-
return {
73-
current: current?.priceComponents.find((component) =>
74-
component.publisher.equals(publisherKey),
75-
),
76-
prev: prev?.priceComponents.find((component) =>
77-
component.publisher.equals(publisherKey),
78-
),
79-
};
80-
};
81-
8217
export const LivePrice = ({
8318
feedKey,
8419
publisherKey,
@@ -93,7 +28,7 @@ export const LivePrice = ({
9328
);
9429

9530
const LiveAggregatePrice = ({ feedKey }: { feedKey: string }) => {
96-
const { prev, current } = useLivePrice(feedKey);
31+
const { prev, current } = useLivePriceData(feedKey);
9732
return (
9833
<Price current={current?.aggregate.price} prev={prev?.aggregate.price} />
9934
);
@@ -117,7 +52,7 @@ const Price = ({
11752
prev?: number | undefined;
11853
current?: number | undefined;
11954
}) => {
120-
const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
55+
const numberFormatter = useNumberFormatter({ maximumFractionDigits: 5 });
12156

12257
return current === undefined ? (
12358
<Skeleton width={SKELETON_WIDTH} />
@@ -145,7 +80,7 @@ export const LiveConfidence = ({
14580
);
14681

14782
const LiveAggregateConfidence = ({ feedKey }: { feedKey: string }) => {
148-
const { current } = useLivePrice(feedKey);
83+
const { current } = useLivePriceData(feedKey);
14984
return <Confidence confidence={current?.aggregate.confidence} />;
15085
};
15186

@@ -161,7 +96,7 @@ const LiveComponentConfidence = ({
16196
};
16297

16398
const Confidence = ({ confidence }: { confidence?: number | undefined }) => {
164-
const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
99+
const numberFormatter = useNumberFormatter({ maximumFractionDigits: 5 });
165100

166101
return (
167102
<span className={styles.confidence}>
@@ -176,7 +111,7 @@ const Confidence = ({ confidence }: { confidence?: number | undefined }) => {
176111
};
177112

178113
export const LiveLastUpdated = ({ feedKey }: { feedKey: string }) => {
179-
const { current } = useLivePrice(feedKey);
114+
const { current } = useLivePriceData(feedKey);
180115
const formatterWithDate = useDateFormatter({
181116
dateStyle: "short",
182117
timeStyle: "medium",
@@ -209,7 +144,7 @@ export const LiveValue = <T extends keyof PriceData>({
209144
field,
210145
defaultValue,
211146
}: LiveValueProps<T>) => {
212-
const { current } = useLivePrice(feedKey);
147+
const { current } = useLivePriceData(feedKey);
213148

214149
return current?.[field]?.toString() ?? defaultValue;
215150
};
@@ -241,109 +176,6 @@ const isToday = (date: Date) => {
241176
);
242177
};
243178

244-
const usePriceData = () => {
245-
const feedSubscriptions = useMap<string, number>([]);
246-
const [feedKeys, setFeedKeys] = useState<string[]>([]);
247-
const prevPriceData = useMap<string, PriceData>([]);
248-
const priceData = useMap<string, PriceData>([]);
249-
const logger = useLogger();
250-
251-
useEffect(() => {
252-
// First, we initialize prices with the last available price. This way, if
253-
// there's any symbol that isn't currently publishing prices (e.g. the
254-
// markets are closed), we will still display the last published price for
255-
// that symbol.
256-
const uninitializedFeedKeys = feedKeys.filter((key) => !priceData.has(key));
257-
if (uninitializedFeedKeys.length > 0) {
258-
getAssetPricesFromAccounts(
259-
Cluster.Pythnet,
260-
uninitializedFeedKeys.map((key) => new PublicKey(key)),
261-
)
262-
.then((initialPrices) => {
263-
for (const [i, price] of initialPrices.entries()) {
264-
const key = uninitializedFeedKeys[i];
265-
if (key && !priceData.has(key)) {
266-
priceData.set(key, price);
267-
}
268-
}
269-
})
270-
.catch((error: unknown) => {
271-
logger.error("Failed to fetch initial prices", error);
272-
});
273-
}
274-
275-
// Then, we create a subscription to update prices live.
276-
const connection = subscribe(
277-
Cluster.Pythnet,
278-
feedKeys.map((key) => new PublicKey(key)),
279-
({ price_account }, data) => {
280-
if (price_account) {
281-
const prevData = priceData.get(price_account);
282-
if (prevData) {
283-
prevPriceData.set(price_account, prevData);
284-
}
285-
priceData.set(price_account, data);
286-
}
287-
},
288-
);
289-
290-
connection.start().catch((error: unknown) => {
291-
logger.error("Failed to subscribe to prices", error);
292-
});
293-
return () => {
294-
connection.stop().catch((error: unknown) => {
295-
logger.error("Failed to unsubscribe from price updates", error);
296-
});
297-
};
298-
}, [feedKeys, logger, priceData, prevPriceData]);
299-
300-
const addSubscription = useCallback(
301-
(key: string) => {
302-
const current = feedSubscriptions.get(key) ?? 0;
303-
feedSubscriptions.set(key, current + 1);
304-
if (current === 0) {
305-
setFeedKeys((prev) => [...new Set([...prev, key])]);
306-
}
307-
},
308-
[feedSubscriptions],
309-
);
310-
311-
const removeSubscription = useCallback(
312-
(key: string) => {
313-
const current = feedSubscriptions.get(key);
314-
if (current) {
315-
feedSubscriptions.set(key, current - 1);
316-
if (current === 1) {
317-
setFeedKeys((prev) => prev.filter((elem) => elem !== key));
318-
}
319-
}
320-
},
321-
[feedSubscriptions],
322-
);
323-
324-
return {
325-
priceData: new Map(priceData),
326-
prevPriceData: new Map(prevPriceData),
327-
addSubscription,
328-
removeSubscription,
329-
};
330-
};
331-
332-
const useLivePrices = () => {
333-
const prices = use(LivePricesContext);
334-
if (prices === undefined) {
335-
throw new LivePricesProviderNotInitializedError();
336-
}
337-
return prices;
338-
};
339-
340-
class LivePricesProviderNotInitializedError extends Error {
341-
constructor() {
342-
super("This component must be a child of <LivePricesProvider>");
343-
this.name = "LivePricesProviderNotInitializedError";
344-
}
345-
}
346-
347179
const getChangeDirection = (
348180
prevPrice: number | undefined,
349181
price: number,

apps/insights/src/components/PriceComponentDrawer/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import { type ReactNode, useState, useRef, useCallback } from "react";
77
import { z } from "zod";
88

99
import styles from "./index.module.scss";
10+
import { StateType, useData } from "../../hooks/use-data";
1011
import { Cluster, ClusterToName } from "../../services/pyth";
1112
import type { Status } from "../../status";
12-
import { StateType, useData } from "../../use-data";
1313
import { LiveConfidence, LivePrice, LiveComponentValue } from "../LivePrices";
1414
import { Score } from "../Score";
1515
import { ScoreHistory as ScoreHistoryComponent } from "../ScoreHistory";

apps/insights/src/components/PriceFeed/chart.module.scss renamed to apps/insights/src/components/PriceFeed/chart-page.module.scss

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
.chartCard {
44
.chart {
55
background: theme.color("background", "primary");
6-
border-radius: theme.border-radius("lg");
6+
height: theme.spacing(140);
7+
border-radius: theme.border-radius("xl");
8+
overflow: hidden;
79
}
810
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Card } from "@pythnetwork/component-library/Card";
2+
import { notFound } from "next/navigation";
3+
4+
import { Chart } from "./chart";
5+
import styles from "./chart-page.module.scss";
6+
import { Cluster, getData } from "../../services/pyth";
7+
8+
type Props = {
9+
params: Promise<{
10+
slug: string;
11+
}>;
12+
};
13+
14+
export const ChartPage = async ({ params }: Props) => {
15+
const [{ slug }, data] = await Promise.all([
16+
params,
17+
getData(Cluster.Pythnet),
18+
]);
19+
const symbol = decodeURIComponent(slug);
20+
const feed = data.find((item) => item.symbol === symbol);
21+
22+
return feed ? (
23+
<Card title="Chart" className={styles.chartCard}>
24+
<div className={styles.chart}>
25+
<Chart symbol={symbol} feedId={feed.product.price_account} />
26+
</div>
27+
</Card>
28+
) : (
29+
notFound()
30+
);
31+
};

0 commit comments

Comments
 (0)