Skip to content

feat(insights): initial version of price chart #2301

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 27, 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
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
Loading