Skip to content

Commit

Permalink
Merge pull request #2301 from pyth-network/cprussin/insights-hub-pric…
Browse files Browse the repository at this point in the history
…e-chart

feat(insights): initial version of price chart
  • Loading branch information
cprussin authored Jan 27, 2025
2 parents c99f0f0 + e2a7831 commit f954684
Show file tree
Hide file tree
Showing 25 changed files with 680 additions and 230 deletions.
1 change: 1 addition & 0 deletions apps/insights/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"clsx": "catalog:",
"cryptocurrency-icons": "catalog:",
"dnum": "catalog:",
"lightweight-charts": "catalog:",
"motion": "catalog:",
"next": "catalog:",
"next-themes": "catalog:",
Expand Down
14 changes: 14 additions & 0 deletions apps/insights/src/app/historical-prices/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { NextRequest } from "next/server";

import { getHistoricalPrices } from "../../services/clickhouse";

export async function GET(req: NextRequest) {
const symbol = req.nextUrl.searchParams.get("symbol");
const until = req.nextUrl.searchParams.get("until");
if (symbol && until) {
const res = await getHistoricalPrices(decodeURIComponent(symbol), until);
return Response.json(res);
} else {
return new Response("Must provide `symbol` and `until`", { status: 400 });
}
}
2 changes: 1 addition & 1 deletion apps/insights/src/app/price-feeds/[slug]/page.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { Chart as default } from "../../../components/PriceFeed/chart";
export { ChartPage as default } from "../../../components/PriceFeed/chart-page";
5 changes: 3 additions & 2 deletions apps/insights/src/components/ChangePercent/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ComponentProps } from "react";

import { omitKeys } from "../../omit-keys";
import { ChangeValue } from "../ChangeValue";
import { FormattedNumber } from "../FormattedNumber";

Expand All @@ -17,13 +18,13 @@ type PriceDifferenceProps = Omit<
}
);

export const ChangePercent = ({ ...props }: PriceDifferenceProps) =>
export const ChangePercent = (props: PriceDifferenceProps) =>
props.isLoading ? (
<ChangeValue {...props} />
) : (
<ChangeValue
direction={getDirection(props.currentValue, props.previousValue)}
{...props}
{...omitKeys(props, ["currentValue", "previousValue"])}
>
<FormattedNumber
maximumFractionDigits={2}
Expand Down
188 changes: 10 additions & 178 deletions apps/insights/src/components/LivePrices/index.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,19 @@
"use client";

import { PlusMinus } from "@phosphor-icons/react/dist/ssr/PlusMinus";
import { useLogger } from "@pythnetwork/app-logger";
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";
import {
type ComponentProps,
type ReactNode,
use,
createContext,
useEffect,
useCallback,
useState,
useMemo,
} from "react";
import { type ReactNode, useMemo } from "react";
import { useNumberFormatter, useDateFormatter } from "react-aria";

import styles from "./index.module.scss";
import {
Cluster,
subscribe,
getAssetPricesFromAccounts,
} from "../../services/pyth";
useLivePriceComponent,
useLivePriceData,
} from "../../hooks/use-live-price-data";

export const SKELETON_WIDTH = 20;

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

type LivePricesProviderProps = Omit<
ComponentProps<typeof LivePricesContext>,
"value"
>;

export const LivePricesProvider = (props: LivePricesProviderProps) => {
const priceData = usePriceData();

return <LivePricesContext value={priceData} {...props} />;
};

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

useEffect(() => {
addSubscription(feedKey);
return () => {
removeSubscription(feedKey);
};
}, [addSubscription, removeSubscription, 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,
Expand All @@ -93,7 +28,7 @@ export const LivePrice = ({
);

const LiveAggregatePrice = ({ feedKey }: { feedKey: string }) => {
const { prev, current } = useLivePrice(feedKey);
const { prev, current } = useLivePriceData(feedKey);
return (
<Price current={current?.aggregate.price} prev={prev?.aggregate.price} />
);
Expand All @@ -117,7 +52,7 @@ const Price = ({
prev?: number | undefined;
current?: number | undefined;
}) => {
const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
const numberFormatter = useNumberFormatter({ maximumFractionDigits: 5 });

return current === undefined ? (
<Skeleton width={SKELETON_WIDTH} />
Expand Down Expand Up @@ -145,7 +80,7 @@ export const LiveConfidence = ({
);

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

Expand All @@ -161,7 +96,7 @@ const LiveComponentConfidence = ({
};

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

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

export const LiveLastUpdated = ({ feedKey }: { feedKey: string }) => {
const { current } = useLivePrice(feedKey);
const { current } = useLivePriceData(feedKey);
const formatterWithDate = useDateFormatter({
dateStyle: "short",
timeStyle: "medium",
Expand Down Expand Up @@ -209,7 +144,7 @@ export const LiveValue = <T extends keyof PriceData>({
field,
defaultValue,
}: LiveValueProps<T>) => {
const { current } = useLivePrice(feedKey);
const { current } = useLivePriceData(feedKey);

return current?.[field]?.toString() ?? defaultValue;
};
Expand Down Expand Up @@ -241,109 +176,6 @@ const isToday = (date: Date) => {
);
};

const usePriceData = () => {
const feedSubscriptions = useMap<string, number>([]);
const [feedKeys, setFeedKeys] = useState<string[]>([]);
const prevPriceData = useMap<string, PriceData>([]);
const priceData = useMap<string, PriceData>([]);
const logger = useLogger();

useEffect(() => {
// First, we initialize prices with the last available price. This way, if
// there's any symbol that isn't currently publishing prices (e.g. the
// markets are closed), we will still display the last published price for
// that symbol.
const uninitializedFeedKeys = feedKeys.filter((key) => !priceData.has(key));
if (uninitializedFeedKeys.length > 0) {
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.has(key)) {
priceData.set(key, price);
}
}
})
.catch((error: unknown) => {
logger.error("Failed to fetch initial prices", error);
});
}

// Then, we create a subscription to update prices live.
const connection = subscribe(
Cluster.Pythnet,
feedKeys.map((key) => new PublicKey(key)),
({ price_account }, data) => {
if (price_account) {
const prevData = priceData.get(price_account);
if (prevData) {
prevPriceData.set(price_account, prevData);
}
priceData.set(price_account, data);
}
},
);

connection.start().catch((error: unknown) => {
logger.error("Failed to subscribe to prices", error);
});
return () => {
connection.stop().catch((error: unknown) => {
logger.error("Failed to unsubscribe from price updates", error);
});
};
}, [feedKeys, logger, priceData, prevPriceData]);

const addSubscription = useCallback(
(key: string) => {
const current = feedSubscriptions.get(key) ?? 0;
feedSubscriptions.set(key, current + 1);
if (current === 0) {
setFeedKeys((prev) => [...new Set([...prev, key])]);
}
},
[feedSubscriptions],
);

const removeSubscription = useCallback(
(key: string) => {
const current = feedSubscriptions.get(key);
if (current) {
feedSubscriptions.set(key, current - 1);
if (current === 1) {
setFeedKeys((prev) => prev.filter((elem) => elem !== key));
}
}
},
[feedSubscriptions],
);

return {
priceData: new Map(priceData),
prevPriceData: new Map(prevPriceData),
addSubscription,
removeSubscription,
};
};

const useLivePrices = () => {
const prices = use(LivePricesContext);
if (prices === undefined) {
throw new LivePricesProviderNotInitializedError();
}
return prices;
};

class LivePricesProviderNotInitializedError extends Error {
constructor() {
super("This component must be a child of <LivePricesProvider>");
this.name = "LivePricesProviderNotInitializedError";
}
}

const getChangeDirection = (
prevPrice: number | undefined,
price: number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { type ReactNode, useState, useRef, useCallback } from "react";
import { z } from "zod";

import styles from "./index.module.scss";
import { StateType, useData } from "../../hooks/use-data";
import { Cluster, ClusterToName } from "../../services/pyth";
import type { Status } from "../../status";
import { StateType, useData } from "../../use-data";
import { LiveConfidence, LivePrice, LiveComponentValue } from "../LivePrices";
import { Score } from "../Score";
import { ScoreHistory as ScoreHistoryComponent } from "../ScoreHistory";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
.chartCard {
.chart {
background: theme.color("background", "primary");
border-radius: theme.border-radius("lg");
height: theme.spacing(140);
border-radius: theme.border-radius("xl");
overflow: hidden;
}
}
31 changes: 31 additions & 0 deletions apps/insights/src/components/PriceFeed/chart-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Card } from "@pythnetwork/component-library/Card";
import { notFound } from "next/navigation";

import { Chart } from "./chart";
import styles from "./chart-page.module.scss";
import { Cluster, getData } from "../../services/pyth";

type Props = {
params: Promise<{
slug: string;
}>;
};

export const ChartPage = async ({ params }: Props) => {
const [{ slug }, data] = await Promise.all([
params,
getData(Cluster.Pythnet),
]);
const symbol = decodeURIComponent(slug);
const feed = data.find((item) => item.symbol === symbol);

return feed ? (
<Card title="Chart" className={styles.chartCard}>
<div className={styles.chart}>
<Chart symbol={symbol} feedId={feed.product.price_account} />
</div>
</Card>
) : (
notFound()
);
};
Loading

0 comments on commit f954684

Please sign in to comment.