Skip to content

feat(insights): add price component drawer #2223

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/insights/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
"recharts": "catalog:",
"superjson": "catalog:",
"swr": "catalog:",
"zod": "catalog:"
"zod": "catalog:",
"zod-validation-error": "catalog:"
},
"devDependencies": {
"@cprussin/eslint-config": "catalog:",
Expand Down
32 changes: 32 additions & 0 deletions apps/insights/src/app/component-score-history/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { fromError } from "zod-validation-error";

import { getFeedScoreHistory } from "../../services/clickhouse";
import { CLUSTER_NAMES, toCluster } from "../../services/pyth";

export const GET = async (req: NextRequest) => {
const parsed = queryParamsSchema.safeParse(
Object.fromEntries(
Object.keys(queryParamsSchema.shape).map((key) => [
key,
req.nextUrl.searchParams.get(key),
]),
),
);
if (parsed.success) {
const { cluster, publisherKey, symbol } = parsed.data;
const data = await getFeedScoreHistory(cluster, publisherKey, symbol);
return Response.json(data);
} else {
return new Response(fromError(parsed.error).toString(), {
status: 400,
});
}
};

const queryParamsSchema = z.object({
cluster: z.enum(CLUSTER_NAMES).transform((value) => toCluster(value)),
publisherKey: z.string(),
symbol: z.string().transform((value) => decodeURIComponent(value)),
});
4 changes: 2 additions & 2 deletions apps/insights/src/app/price-feeds/[slug]/layout.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { Metadata } from "next";

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

export const metadata: Metadata = {
title: "Price Feeds",
};

export const generateStaticParams = async () => {
const data = await getData();
const data = await getData(Cluster.Pythnet);
return data.map(({ symbol }) => ({ slug: encodeURIComponent(symbol) }));
};
6 changes: 3 additions & 3 deletions apps/insights/src/components/ChangePercent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,14 @@ const ChangePercentLoaded = ({
priorPrice,
feedKey,
}: ChangePercentLoadedProps) => {
const currentPrice = useLivePrice(feedKey);
const { current } = useLivePrice(feedKey);

return currentPrice === undefined ? (
return current === undefined ? (
<ChangeValue className={className} isLoading />
) : (
<PriceDifference
className={className}
currentPrice={currentPrice.aggregate.price}
currentPrice={current.aggregate.price}
priorPrice={priorPrice}
/>
);
Expand Down
190 changes: 151 additions & 39 deletions apps/insights/src/components/LivePrices/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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

import styles from "./index.module.scss";
import { client, subscribe } from "../../services/pyth";
import {
Cluster,
subscribe,
getAssetPricesFromAccounts,
} from "../../services/pyth";

export const SKELETON_WIDTH = 20;

const LivePricesContext = createContext<
ReturnType<typeof usePriceData> | undefined
>(undefined);

type Price = PriceData & {
direction: ChangeDirection;
};

type ChangeDirection = "up" | "down" | "flat";

type LivePricesProviderProps = Omit<
ComponentProps<typeof LivePricesContext>,
"value"
Expand All @@ -45,7 +43,8 @@ export const LivePricesProvider = (props: LivePricesProviderProps) => {
};

export const useLivePrice = (feedKey: string) => {
const { priceData, addSubscription, removeSubscription } = useLivePrices();
const { priceData, prevPriceData, addSubscription, removeSubscription } =
useLivePrices();

useEffect(() => {
addSubscription(feedKey);
Expand All @@ -54,40 +53,130 @@ export const useLivePrice = (feedKey: string) => {
};
}, [addSubscription, removeSubscription, feedKey]);

return priceData.get(feedKey);
const current = priceData.get(feedKey);
const prev = prevPriceData.get(feedKey);

return { current, prev };
};

export const useLivePriceComponent = (
feedKey: string,
publisherKeyAsBase58: string,
) => {
const { current, prev } = useLivePrice(feedKey);
const publisherKey = useMemo(
() => new PublicKey(publisherKeyAsBase58),
[publisherKeyAsBase58],
);

return {
current: current?.priceComponents.find((component) =>
component.publisher.equals(publisherKey),
),
prev: prev?.priceComponents.find((component) =>
component.publisher.equals(publisherKey),
),
};
};

export const LivePrice = ({
feedKey,
publisherKey,
}: {
feedKey: string;
publisherKey?: string | undefined;
}) =>
publisherKey ? (
<LiveComponentPrice feedKey={feedKey} publisherKey={publisherKey} />
) : (
<LiveAggregatePrice feedKey={feedKey} />
);

const LiveAggregatePrice = ({ feedKey }: { feedKey: string }) => {
const { prev, current } = useLivePrice(feedKey);
return (
<Price current={current?.aggregate.price} prev={prev?.aggregate.price} />
);
};

const LiveComponentPrice = ({
feedKey,
publisherKey,
}: {
feedKey: string;
publisherKey: string;
}) => {
const { prev, current } = useLivePriceComponent(feedKey, publisherKey);
return <Price current={current?.latest.price} prev={prev?.latest.price} />;
};

export const LivePrice = ({ feedKey }: { feedKey: string }) => {
const Price = ({
prev,
current,
}: {
prev?: number | undefined;
current?: number | undefined;
}) => {
const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
const price = useLivePrice(feedKey);

return price === undefined ? (
return current === undefined ? (
<Skeleton width={SKELETON_WIDTH} />
) : (
<span className={styles.price} data-direction={price.direction}>
{numberFormatter.format(price.aggregate.price)}
<span
className={styles.price}
data-direction={prev ? getChangeDirection(prev, current) : "flat"}
>
{numberFormatter.format(current)}
</span>
);
};

export const LiveConfidence = ({ feedKey }: { feedKey: string }) => {
export const LiveConfidence = ({
feedKey,
publisherKey,
}: {
feedKey: string;
publisherKey?: string | undefined;
}) =>
publisherKey === undefined ? (
<LiveAggregateConfidence feedKey={feedKey} />
) : (
<LiveComponentConfidence feedKey={feedKey} publisherKey={publisherKey} />
);

const LiveAggregateConfidence = ({ feedKey }: { feedKey: string }) => {
const { current } = useLivePrice(feedKey);
return <Confidence confidence={current?.aggregate.confidence} />;
};

const LiveComponentConfidence = ({
feedKey,
publisherKey,
}: {
feedKey: string;
publisherKey: string;
}) => {
const { current } = useLivePriceComponent(feedKey, publisherKey);
return <Confidence confidence={current?.latest.confidence} />;
};

const Confidence = ({ confidence }: { confidence?: number | undefined }) => {
const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
const price = useLivePrice(feedKey);

return (
<span className={styles.confidence}>
<PlusMinus className={styles.plusMinus} />
{price === undefined ? (
{confidence === undefined ? (
<Skeleton width={SKELETON_WIDTH} />
) : (
<span>{numberFormatter.format(price.aggregate.confidence)}</span>
<span>{numberFormatter.format(confidence)}</span>
)}
</span>
);
};

export const LiveLastUpdated = ({ feedKey }: { feedKey: string }) => {
const price = useLivePrice(feedKey);
const { current } = useLivePrice(feedKey);
const formatterWithDate = useDateFormatter({
dateStyle: "short",
timeStyle: "medium",
Expand All @@ -96,15 +185,15 @@ export const LiveLastUpdated = ({ feedKey }: { feedKey: string }) => {
timeStyle: "medium",
});
const formattedTimestamp = useMemo(() => {
if (price) {
const timestamp = new Date(Number(price.timestamp * 1000n));
if (current) {
const timestamp = new Date(Number(current.timestamp * 1000n));
return isToday(timestamp)
? formatterWithoutDate.format(timestamp)
: formatterWithDate.format(timestamp);
} else {
return;
}
}, [price, formatterWithDate, formatterWithoutDate]);
}, [current, formatterWithDate, formatterWithoutDate]);

return formattedTimestamp ?? <Skeleton width={SKELETON_WIDTH} />;
};
Expand All @@ -120,9 +209,27 @@ export const LiveValue = <T extends keyof PriceData>({
field,
defaultValue,
}: LiveValueProps<T>) => {
const price = useLivePrice(feedKey);
const { current } = useLivePrice(feedKey);

return price?.[field]?.toString() ?? defaultValue;
return current?.[field]?.toString() ?? defaultValue;
};

type LiveComponentValueProps<T extends keyof PriceComponent["latest"]> = {
field: T;
feedKey: string;
publisherKey: string;
defaultValue?: ReactNode | undefined;
};

export const LiveComponentValue = <T extends keyof PriceComponent["latest"]>({
feedKey,
field,
publisherKey,
defaultValue,
}: LiveComponentValueProps<T>) => {
const { current } = useLivePriceComponent(feedKey, publisherKey);

return current?.latest[field].toString() ?? defaultValue;
};

const isToday = (date: Date) => {
Expand All @@ -137,7 +244,8 @@ const isToday = (date: Date) => {
const usePriceData = () => {
const feedSubscriptions = useMap<string, number>([]);
const [feedKeys, setFeedKeys] = useState<string[]>([]);
const priceData = useMap<string, Price>([]);
const prevPriceData = useMap<string, PriceData>([]);
const priceData = useMap<string, PriceData>([]);
const logger = useLogger();

useEffect(() => {
Expand All @@ -147,15 +255,15 @@ const usePriceData = () => {
// that symbol.
const uninitializedFeedKeys = feedKeys.filter((key) => !priceData.has(key));
if (uninitializedFeedKeys.length > 0) {
client
.getAssetPricesFromAccounts(
uninitializedFeedKeys.map((key) => new PublicKey(key)),
)
getAssetPricesFromAccounts(
Cluster.Pythnet,
uninitializedFeedKeys.map((key) => new PublicKey(key)),
)
.then((initialPrices) => {
for (const [i, price] of initialPrices.entries()) {
const key = uninitializedFeedKeys[i];
if (key) {
priceData.set(key, { ...price, direction: "flat" });
if (key && !priceData.has(key)) {
priceData.set(key, price);
}
}
})
Expand All @@ -166,14 +274,15 @@ const usePriceData = () => {

// Then, we create a subscription to update prices live.
const connection = subscribe(
Cluster.Pythnet,
feedKeys.map((key) => new PublicKey(key)),
({ price_account }, price) => {
({ price_account }, data) => {
if (price_account) {
const prevPrice = priceData.get(price_account)?.price;
priceData.set(price_account, {
...price,
direction: getChangeDirection(prevPrice, price.aggregate.price),
});
const prevData = priceData.get(price_account);
if (prevData) {
prevPriceData.set(price_account, prevData);
}
priceData.set(price_account, data);
}
},
);
Expand All @@ -186,7 +295,7 @@ const usePriceData = () => {
logger.error("Failed to unsubscribe from price updates", error);
});
};
}, [feedKeys, logger, priceData]);
}, [feedKeys, logger, priceData, prevPriceData]);

const addSubscription = useCallback(
(key: string) => {
Expand Down Expand Up @@ -214,6 +323,7 @@ const usePriceData = () => {

return {
priceData: new Map(priceData),
prevPriceData: new Map(prevPriceData),
addSubscription,
removeSubscription,
};
Expand Down Expand Up @@ -246,3 +356,5 @@ const getChangeDirection = (
return "down";
}
};

type ChangeDirection = "up" | "down" | "flat";
Loading
Loading