Skip to content

Commit f6558c9

Browse files
committed
feat(insights): finish price feeds index
1 parent 7259cb2 commit f6558c9

File tree

80 files changed

+4575
-2923
lines changed

Some content is hidden

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

80 files changed

+4575
-2923
lines changed

apps/insights/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@pythnetwork/next-root": "workspace:*",
3131
"@react-hookz/web": "catalog:",
3232
"@solana/web3.js": "catalog:",
33+
"bs58": "catalog:",
3334
"clsx": "catalog:",
3435
"cryptocurrency-icons": "catalog:",
3536
"framer-motion": "catalog:",
@@ -40,6 +41,7 @@
4041
"react-aria": "catalog:",
4142
"react-aria-components": "catalog:",
4243
"react-dom": "catalog:",
44+
"swr": "catalog:",
4345
"zod": "catalog:"
4446
},
4547
"devDependencies": {

apps/insights/src/app/loading.tsx

Lines changed: 0 additions & 1 deletion
This file was deleted.

apps/insights/src/app/price-feeds/layout.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

apps/insights/src/app/price-feeds/loading.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { NextRequest } from "next/server";
2+
import { z } from "zod";
3+
4+
import { client } from "../../clickhouse";
5+
6+
export async function GET(req: NextRequest) {
7+
const symbols = req.nextUrl.searchParams.getAll("symbols");
8+
const rows = await client.query({
9+
query:
10+
"select * from insights_yesterdays_prices(symbols={symbols: Array(String)})",
11+
query_params: { symbols },
12+
});
13+
const result = await rows.json();
14+
const data = schema.parse(result.data);
15+
return Response.json(
16+
Object.fromEntries(data.map(({ symbol, price }) => [symbol, price])),
17+
);
18+
}
19+
20+
const schema = z.array(
21+
z.object({
22+
symbol: z.string(),
23+
price: z.number(),
24+
}),
25+
);

apps/insights/src/components/CopyButton/index.module.scss

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,31 @@
11
@use "@pythnetwork/component-library/theme";
22

33
.copyButton {
4-
margin: -#{theme.spacing(0.5)} -0.5em;
4+
margin: -#{theme.spacing(0.5)} -#{theme.spacing(1)};
55
display: inline-block;
66
white-space: nowrap;
77
border-radius: theme.border-radius("md");
8-
padding: theme.spacing(0.5) 0.5em;
9-
border: none;
8+
padding: theme.spacing(0.5) theme.spacing(1);
109
background: none;
1110
cursor: pointer;
12-
transition: background-color 100ms linear;
13-
outline: none;
11+
transition-property: background-color, color, border-color, outline-color;
12+
transition-duration: 100ms;
13+
transition-timing-function: linear;
14+
border: 1px solid transparent;
15+
outline-offset: 0;
16+
outline: theme.spacing(1) solid transparent;
1417

1518
.iconContainer {
1619
position: relative;
1720
top: 0.125em;
1821
margin-left: theme.spacing(1);
1922
display: inline-block;
2023

21-
.copyIconContainer {
24+
.copyIcon {
2225
opacity: 0.5;
2326
transition: opacity 100ms linear;
24-
25-
.copyIcon {
26-
width: 1em;
27-
height: 1em;
28-
}
29-
30-
.copyIconLabel {
31-
@include theme.sr-only;
32-
}
27+
width: 1em;
28+
height: 1em;
3329
}
3430

3531
.checkIcon {
@@ -50,12 +46,12 @@
5046
}
5147

5248
&[data-focus-visible] {
53-
outline: 1px solid currentcolor;
54-
outline-offset: theme.spacing(1);
49+
border-color: theme.color("focus");
50+
outline-color: theme.color("focus-dim");
5551
}
5652

5753
&[data-is-copied] .iconContainer {
58-
.copyIconContainer {
54+
.copyIcon {
5955
opacity: 0;
6056
}
6157

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,7 @@ export const CopyButton = ({
6464
{typeof children === "function" ? children(...args) : children}
6565
</span>
6666
<span className={styles.iconContainer}>
67-
<span className={styles.copyIconContainer}>
68-
<Copy className={styles.copyIcon} />
69-
<div className={styles.copyIconLabel}>Copy to clipboard</div>
70-
</span>
67+
<Copy className={styles.copyIcon} />
7168
<Check className={styles.checkIcon} />
7269
</span>
7370
</>

apps/insights/src/components/H1/index.module.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
.h1 {
44
font-size: theme.font-size("2xl");
55
font-weight: theme.font-weight("medium");
6+
margin: 0;
67
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
@use "@pythnetwork/component-library/theme";
2+
3+
.price {
4+
transition: color 100ms linear;
5+
6+
&[data-direction="up"] {
7+
color: theme.color("states", "success", "base");
8+
}
9+
10+
&[data-direction="down"] {
11+
color: theme.color("states", "error", "base");
12+
}
13+
}
14+
15+
.confidence {
16+
display: flex;
17+
flex-flow: row nowrap;
18+
gap: theme.spacing(2);
19+
align-items: center;
20+
21+
.plusMinus {
22+
width: theme.spacing(4);
23+
height: theme.spacing(4);
24+
display: inline-block;
25+
color: theme.color("muted");
26+
}
27+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"use client";
2+
3+
import { PlusMinus } from "@phosphor-icons/react/dist/ssr/PlusMinus";
4+
import { useLogger } from "@pythnetwork/app-logger";
5+
import { Skeleton } from "@pythnetwork/component-library/Skeleton";
6+
import { useMap } from "@react-hookz/web";
7+
import { PublicKey } from "@solana/web3.js";
8+
import {
9+
type ComponentProps,
10+
use,
11+
createContext,
12+
useEffect,
13+
useCallback,
14+
useState,
15+
} from "react";
16+
import { useNumberFormatter } from "react-aria";
17+
18+
import styles from "./index.module.scss";
19+
import { client, subscribe } from "../../pyth";
20+
21+
export const SKELETON_WIDTH = 20;
22+
23+
const LivePricesContext = createContext<
24+
ReturnType<typeof usePriceData> | undefined
25+
>(undefined);
26+
27+
type Price = {
28+
price: number;
29+
direction: ChangeDirection;
30+
confidence: number;
31+
};
32+
33+
type ChangeDirection = "up" | "down" | "flat";
34+
35+
type LivePricesProviderProps = Omit<
36+
ComponentProps<typeof LivePricesContext>,
37+
"value"
38+
>;
39+
40+
export const LivePricesProvider = ({ ...props }: LivePricesProviderProps) => {
41+
const priceData = usePriceData();
42+
43+
return <LivePricesContext value={priceData} {...props} />;
44+
};
45+
46+
export const useLivePrice = (account: string) => {
47+
const { priceData, addSubscription, removeSubscription } = useLivePrices();
48+
49+
useEffect(() => {
50+
addSubscription(account);
51+
return () => {
52+
removeSubscription(account);
53+
};
54+
}, [addSubscription, removeSubscription, account]);
55+
56+
return priceData.get(account);
57+
};
58+
59+
export const LivePrice = ({ account }: { account: string }) => {
60+
const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
61+
const price = useLivePrice(account);
62+
63+
return price === undefined ? (
64+
<Skeleton width={SKELETON_WIDTH} />
65+
) : (
66+
<span className={styles.price} data-direction={price.direction}>
67+
{numberFormatter.format(price.price)}
68+
</span>
69+
);
70+
};
71+
72+
export const LiveConfidence = ({ account }: { account: string }) => {
73+
const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
74+
const price = useLivePrice(account);
75+
76+
return price === undefined ? (
77+
<Skeleton width={SKELETON_WIDTH} />
78+
) : (
79+
<span className={styles.confidence}>
80+
<PlusMinus className={styles.plusMinus} />
81+
<span>{numberFormatter.format(price.confidence)}</span>
82+
</span>
83+
);
84+
};
85+
86+
const usePriceData = () => {
87+
const feedSubscriptions = useMap<string, number>([]);
88+
const [feedKeys, setFeedKeys] = useState<string[]>([]);
89+
const priceData = useMap<string, Price>([]);
90+
const logger = useLogger();
91+
92+
useEffect(() => {
93+
// First, we initialize prices with the last available price. This way, if
94+
// there's any symbol that isn't currently publishing prices (e.g. the
95+
// markets are closed), we will still display the last published price for
96+
// that symbol.
97+
const uninitializedFeedKeys = feedKeys.filter((key) => !priceData.has(key));
98+
if (uninitializedFeedKeys.length > 0) {
99+
client
100+
.getAssetPricesFromAccounts(
101+
uninitializedFeedKeys.map((key) => new PublicKey(key)),
102+
)
103+
.then((initialPrices) => {
104+
for (const [i, price] of initialPrices.entries()) {
105+
const key = uninitializedFeedKeys[i];
106+
if (key) {
107+
priceData.set(key, {
108+
price: price.aggregate.price,
109+
direction: "flat",
110+
confidence: price.aggregate.confidence,
111+
});
112+
}
113+
}
114+
})
115+
.catch((error: unknown) => {
116+
logger.error("Failed to fetch initial prices", error);
117+
});
118+
}
119+
120+
// Then, we create a subscription to update prices live.
121+
const connection = subscribe(
122+
feedKeys.map((key) => new PublicKey(key)),
123+
({ price_account }, { aggregate }) => {
124+
if (price_account) {
125+
const prevPrice = priceData.get(price_account)?.price;
126+
priceData.set(price_account, {
127+
price: aggregate.price,
128+
direction: getChangeDirection(prevPrice, aggregate.price),
129+
confidence: aggregate.confidence,
130+
});
131+
}
132+
},
133+
);
134+
135+
connection.start().catch((error: unknown) => {
136+
logger.error("Failed to subscribe to prices", error);
137+
});
138+
return () => {
139+
connection.stop().catch((error: unknown) => {
140+
logger.error("Failed to unsubscribe from price updates", error);
141+
});
142+
};
143+
}, [feedKeys, logger, priceData]);
144+
145+
const addSubscription = useCallback(
146+
(key: string) => {
147+
const current = feedSubscriptions.get(key) ?? 0;
148+
feedSubscriptions.set(key, current + 1);
149+
if (current === 0) {
150+
setFeedKeys((prev) => [...new Set([...prev, key])]);
151+
}
152+
},
153+
[feedSubscriptions],
154+
);
155+
156+
const removeSubscription = useCallback(
157+
(key: string) => {
158+
const current = feedSubscriptions.get(key);
159+
if (current) {
160+
feedSubscriptions.set(key, current - 1);
161+
if (current === 1) {
162+
setFeedKeys((prev) => prev.filter((elem) => elem !== key));
163+
}
164+
}
165+
},
166+
[feedSubscriptions],
167+
);
168+
169+
return {
170+
priceData: new Map(priceData),
171+
addSubscription,
172+
removeSubscription,
173+
};
174+
};
175+
176+
const useLivePrices = () => {
177+
const prices = use(LivePricesContext);
178+
if (prices === undefined) {
179+
throw new LivePricesProviderNotInitializedError();
180+
}
181+
return prices;
182+
};
183+
184+
class LivePricesProviderNotInitializedError extends Error {
185+
constructor() {
186+
super("This component must be a child of <LivePricesProvider>");
187+
this.name = "LivePricesProviderNotInitializedError";
188+
}
189+
}
190+
191+
const getChangeDirection = (
192+
prevPrice: number | undefined,
193+
price: number,
194+
): ChangeDirection => {
195+
if (prevPrice === undefined || prevPrice === price) {
196+
return "flat";
197+
} else if (prevPrice < price) {
198+
return "up";
199+
} else {
200+
return "down";
201+
}
202+
};

0 commit comments

Comments
 (0)