Skip to content

Commit b0a75f6

Browse files
authored
Merge pull request #2223 from pyth-network/cprussin/add-price-feed-component-drawer
feat(insights): add price component drawer
2 parents 512f637 + 58cdee2 commit b0a75f6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+2365
-996
lines changed

apps/insights/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
"recharts": "catalog:",
4646
"superjson": "catalog:",
4747
"swr": "catalog:",
48-
"zod": "catalog:"
48+
"zod": "catalog:",
49+
"zod-validation-error": "catalog:"
4950
},
5051
"devDependencies": {
5152
"@cprussin/eslint-config": "catalog:",
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { NextRequest } from "next/server";
2+
import { z } from "zod";
3+
import { fromError } from "zod-validation-error";
4+
5+
import { getFeedScoreHistory } from "../../services/clickhouse";
6+
import { CLUSTER_NAMES, toCluster } from "../../services/pyth";
7+
8+
export const GET = async (req: NextRequest) => {
9+
const parsed = queryParamsSchema.safeParse(
10+
Object.fromEntries(
11+
Object.keys(queryParamsSchema.shape).map((key) => [
12+
key,
13+
req.nextUrl.searchParams.get(key),
14+
]),
15+
),
16+
);
17+
if (parsed.success) {
18+
const { cluster, publisherKey, symbol } = parsed.data;
19+
const data = await getFeedScoreHistory(cluster, publisherKey, symbol);
20+
return Response.json(data);
21+
} else {
22+
return new Response(fromError(parsed.error).toString(), {
23+
status: 400,
24+
});
25+
}
26+
};
27+
28+
const queryParamsSchema = z.object({
29+
cluster: z.enum(CLUSTER_NAMES).transform((value) => toCluster(value)),
30+
publisherKey: z.string(),
31+
symbol: z.string().transform((value) => decodeURIComponent(value)),
32+
});
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { Metadata } from "next";
22

3-
import { getData } from "../../../services/pyth";
3+
import { Cluster, getData } from "../../../services/pyth";
44
export { PriceFeedLayout as default } from "../../../components/PriceFeed/layout";
55

66
export const metadata: Metadata = {
77
title: "Price Feeds",
88
};
99

1010
export const generateStaticParams = async () => {
11-
const data = await getData();
11+
const data = await getData(Cluster.Pythnet);
1212
return data.map(({ symbol }) => ({ slug: encodeURIComponent(symbol) }));
1313
};

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,14 @@ const ChangePercentLoaded = ({
100100
priorPrice,
101101
feedKey,
102102
}: ChangePercentLoadedProps) => {
103-
const currentPrice = useLivePrice(feedKey);
103+
const { current } = useLivePrice(feedKey);
104104

105-
return currentPrice === undefined ? (
105+
return current === undefined ? (
106106
<ChangeValue className={className} isLoading />
107107
) : (
108108
<PriceDifference
109109
className={className}
110-
currentPrice={currentPrice.aggregate.price}
110+
currentPrice={current.aggregate.price}
111111
priorPrice={priorPrice}
112112
/>
113113
);

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

Lines changed: 151 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { PlusMinus } from "@phosphor-icons/react/dist/ssr/PlusMinus";
44
import { useLogger } from "@pythnetwork/app-logger";
5-
import type { PriceData } from "@pythnetwork/client";
5+
import type { PriceData, PriceComponent } from "@pythnetwork/client";
66
import { Skeleton } from "@pythnetwork/component-library/Skeleton";
77
import { useMap } from "@react-hookz/web";
88
import { PublicKey } from "@solana/web3.js";
@@ -19,20 +19,18 @@ import {
1919
import { useNumberFormatter, useDateFormatter } from "react-aria";
2020

2121
import styles from "./index.module.scss";
22-
import { client, subscribe } from "../../services/pyth";
22+
import {
23+
Cluster,
24+
subscribe,
25+
getAssetPricesFromAccounts,
26+
} from "../../services/pyth";
2327

2428
export const SKELETON_WIDTH = 20;
2529

2630
const LivePricesContext = createContext<
2731
ReturnType<typeof usePriceData> | undefined
2832
>(undefined);
2933

30-
type Price = PriceData & {
31-
direction: ChangeDirection;
32-
};
33-
34-
type ChangeDirection = "up" | "down" | "flat";
35-
3634
type LivePricesProviderProps = Omit<
3735
ComponentProps<typeof LivePricesContext>,
3836
"value"
@@ -45,7 +43,8 @@ export const LivePricesProvider = (props: LivePricesProviderProps) => {
4543
};
4644

4745
export const useLivePrice = (feedKey: string) => {
48-
const { priceData, addSubscription, removeSubscription } = useLivePrices();
46+
const { priceData, prevPriceData, addSubscription, removeSubscription } =
47+
useLivePrices();
4948

5049
useEffect(() => {
5150
addSubscription(feedKey);
@@ -54,40 +53,130 @@ export const useLivePrice = (feedKey: string) => {
5453
};
5554
}, [addSubscription, removeSubscription, feedKey]);
5655

57-
return priceData.get(feedKey);
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+
82+
export const LivePrice = ({
83+
feedKey,
84+
publisherKey,
85+
}: {
86+
feedKey: string;
87+
publisherKey?: string | undefined;
88+
}) =>
89+
publisherKey ? (
90+
<LiveComponentPrice feedKey={feedKey} publisherKey={publisherKey} />
91+
) : (
92+
<LiveAggregatePrice feedKey={feedKey} />
93+
);
94+
95+
const LiveAggregatePrice = ({ feedKey }: { feedKey: string }) => {
96+
const { prev, current } = useLivePrice(feedKey);
97+
return (
98+
<Price current={current?.aggregate.price} prev={prev?.aggregate.price} />
99+
);
100+
};
101+
102+
const LiveComponentPrice = ({
103+
feedKey,
104+
publisherKey,
105+
}: {
106+
feedKey: string;
107+
publisherKey: string;
108+
}) => {
109+
const { prev, current } = useLivePriceComponent(feedKey, publisherKey);
110+
return <Price current={current?.latest.price} prev={prev?.latest.price} />;
58111
};
59112

60-
export const LivePrice = ({ feedKey }: { feedKey: string }) => {
113+
const Price = ({
114+
prev,
115+
current,
116+
}: {
117+
prev?: number | undefined;
118+
current?: number | undefined;
119+
}) => {
61120
const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
62-
const price = useLivePrice(feedKey);
63121

64-
return price === undefined ? (
122+
return current === undefined ? (
65123
<Skeleton width={SKELETON_WIDTH} />
66124
) : (
67-
<span className={styles.price} data-direction={price.direction}>
68-
{numberFormatter.format(price.aggregate.price)}
125+
<span
126+
className={styles.price}
127+
data-direction={prev ? getChangeDirection(prev, current) : "flat"}
128+
>
129+
{numberFormatter.format(current)}
69130
</span>
70131
);
71132
};
72133

73-
export const LiveConfidence = ({ feedKey }: { feedKey: string }) => {
134+
export const LiveConfidence = ({
135+
feedKey,
136+
publisherKey,
137+
}: {
138+
feedKey: string;
139+
publisherKey?: string | undefined;
140+
}) =>
141+
publisherKey === undefined ? (
142+
<LiveAggregateConfidence feedKey={feedKey} />
143+
) : (
144+
<LiveComponentConfidence feedKey={feedKey} publisherKey={publisherKey} />
145+
);
146+
147+
const LiveAggregateConfidence = ({ feedKey }: { feedKey: string }) => {
148+
const { current } = useLivePrice(feedKey);
149+
return <Confidence confidence={current?.aggregate.confidence} />;
150+
};
151+
152+
const LiveComponentConfidence = ({
153+
feedKey,
154+
publisherKey,
155+
}: {
156+
feedKey: string;
157+
publisherKey: string;
158+
}) => {
159+
const { current } = useLivePriceComponent(feedKey, publisherKey);
160+
return <Confidence confidence={current?.latest.confidence} />;
161+
};
162+
163+
const Confidence = ({ confidence }: { confidence?: number | undefined }) => {
74164
const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
75-
const price = useLivePrice(feedKey);
76165

77166
return (
78167
<span className={styles.confidence}>
79168
<PlusMinus className={styles.plusMinus} />
80-
{price === undefined ? (
169+
{confidence === undefined ? (
81170
<Skeleton width={SKELETON_WIDTH} />
82171
) : (
83-
<span>{numberFormatter.format(price.aggregate.confidence)}</span>
172+
<span>{numberFormatter.format(confidence)}</span>
84173
)}
85174
</span>
86175
);
87176
};
88177

89178
export const LiveLastUpdated = ({ feedKey }: { feedKey: string }) => {
90-
const price = useLivePrice(feedKey);
179+
const { current } = useLivePrice(feedKey);
91180
const formatterWithDate = useDateFormatter({
92181
dateStyle: "short",
93182
timeStyle: "medium",
@@ -96,15 +185,15 @@ export const LiveLastUpdated = ({ feedKey }: { feedKey: string }) => {
96185
timeStyle: "medium",
97186
});
98187
const formattedTimestamp = useMemo(() => {
99-
if (price) {
100-
const timestamp = new Date(Number(price.timestamp * 1000n));
188+
if (current) {
189+
const timestamp = new Date(Number(current.timestamp * 1000n));
101190
return isToday(timestamp)
102191
? formatterWithoutDate.format(timestamp)
103192
: formatterWithDate.format(timestamp);
104193
} else {
105194
return;
106195
}
107-
}, [price, formatterWithDate, formatterWithoutDate]);
196+
}, [current, formatterWithDate, formatterWithoutDate]);
108197

109198
return formattedTimestamp ?? <Skeleton width={SKELETON_WIDTH} />;
110199
};
@@ -120,9 +209,27 @@ export const LiveValue = <T extends keyof PriceData>({
120209
field,
121210
defaultValue,
122211
}: LiveValueProps<T>) => {
123-
const price = useLivePrice(feedKey);
212+
const { current } = useLivePrice(feedKey);
124213

125-
return price?.[field]?.toString() ?? defaultValue;
214+
return current?.[field]?.toString() ?? defaultValue;
215+
};
216+
217+
type LiveComponentValueProps<T extends keyof PriceComponent["latest"]> = {
218+
field: T;
219+
feedKey: string;
220+
publisherKey: string;
221+
defaultValue?: ReactNode | undefined;
222+
};
223+
224+
export const LiveComponentValue = <T extends keyof PriceComponent["latest"]>({
225+
feedKey,
226+
field,
227+
publisherKey,
228+
defaultValue,
229+
}: LiveComponentValueProps<T>) => {
230+
const { current } = useLivePriceComponent(feedKey, publisherKey);
231+
232+
return current?.latest[field].toString() ?? defaultValue;
126233
};
127234

128235
const isToday = (date: Date) => {
@@ -137,7 +244,8 @@ const isToday = (date: Date) => {
137244
const usePriceData = () => {
138245
const feedSubscriptions = useMap<string, number>([]);
139246
const [feedKeys, setFeedKeys] = useState<string[]>([]);
140-
const priceData = useMap<string, Price>([]);
247+
const prevPriceData = useMap<string, PriceData>([]);
248+
const priceData = useMap<string, PriceData>([]);
141249
const logger = useLogger();
142250

143251
useEffect(() => {
@@ -147,15 +255,15 @@ const usePriceData = () => {
147255
// that symbol.
148256
const uninitializedFeedKeys = feedKeys.filter((key) => !priceData.has(key));
149257
if (uninitializedFeedKeys.length > 0) {
150-
client
151-
.getAssetPricesFromAccounts(
152-
uninitializedFeedKeys.map((key) => new PublicKey(key)),
153-
)
258+
getAssetPricesFromAccounts(
259+
Cluster.Pythnet,
260+
uninitializedFeedKeys.map((key) => new PublicKey(key)),
261+
)
154262
.then((initialPrices) => {
155263
for (const [i, price] of initialPrices.entries()) {
156264
const key = uninitializedFeedKeys[i];
157-
if (key) {
158-
priceData.set(key, { ...price, direction: "flat" });
265+
if (key && !priceData.has(key)) {
266+
priceData.set(key, price);
159267
}
160268
}
161269
})
@@ -166,14 +274,15 @@ const usePriceData = () => {
166274

167275
// Then, we create a subscription to update prices live.
168276
const connection = subscribe(
277+
Cluster.Pythnet,
169278
feedKeys.map((key) => new PublicKey(key)),
170-
({ price_account }, price) => {
279+
({ price_account }, data) => {
171280
if (price_account) {
172-
const prevPrice = priceData.get(price_account)?.price;
173-
priceData.set(price_account, {
174-
...price,
175-
direction: getChangeDirection(prevPrice, price.aggregate.price),
176-
});
281+
const prevData = priceData.get(price_account);
282+
if (prevData) {
283+
prevPriceData.set(price_account, prevData);
284+
}
285+
priceData.set(price_account, data);
177286
}
178287
},
179288
);
@@ -186,7 +295,7 @@ const usePriceData = () => {
186295
logger.error("Failed to unsubscribe from price updates", error);
187296
});
188297
};
189-
}, [feedKeys, logger, priceData]);
298+
}, [feedKeys, logger, priceData, prevPriceData]);
190299

191300
const addSubscription = useCallback(
192301
(key: string) => {
@@ -214,6 +323,7 @@ const usePriceData = () => {
214323

215324
return {
216325
priceData: new Map(priceData),
326+
prevPriceData: new Map(prevPriceData),
217327
addSubscription,
218328
removeSubscription,
219329
};
@@ -246,3 +356,5 @@ const getChangeDirection = (
246356
return "down";
247357
}
248358
};
359+
360+
type ChangeDirection = "up" | "down" | "flat";

0 commit comments

Comments
 (0)