Skip to content
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

feat(insights): finish price feeds index #2154

Merged
merged 1 commit into from
Dec 1, 2024
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
2 changes: 2 additions & 0 deletions apps/insights/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@pythnetwork/next-root": "workspace:*",
"@react-hookz/web": "catalog:",
"@solana/web3.js": "catalog:",
"bs58": "catalog:",
"clsx": "catalog:",
"cryptocurrency-icons": "catalog:",
"framer-motion": "catalog:",
Expand All @@ -40,6 +41,7 @@
"react-aria": "catalog:",
"react-aria-components": "catalog:",
"react-dom": "catalog:",
"swr": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
Expand Down
1 change: 0 additions & 1 deletion apps/insights/src/app/loading.tsx

This file was deleted.

1 change: 0 additions & 1 deletion apps/insights/src/app/price-feeds/layout.ts

This file was deleted.

1 change: 0 additions & 1 deletion apps/insights/src/app/price-feeds/loading.ts

This file was deleted.

25 changes: 25 additions & 0 deletions apps/insights/src/app/yesterdays-prices/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { NextRequest } from "next/server";
import { z } from "zod";

import { client } from "../../clickhouse";

export async function GET(req: NextRequest) {
const symbols = req.nextUrl.searchParams.getAll("symbols");
const rows = await client.query({
query:
"select * from insights_yesterdays_prices(symbols={symbols: Array(String)})",
query_params: { symbols },
});
const result = await rows.json();
const data = schema.parse(result.data);
return Response.json(
Object.fromEntries(data.map(({ symbol, price }) => [symbol, price])),
);
}

const schema = z.array(
z.object({
symbol: z.string(),
price: z.number(),
}),
);
32 changes: 14 additions & 18 deletions apps/insights/src/components/CopyButton/index.module.scss
Original file line number Diff line number Diff line change
@@ -1,35 +1,31 @@
@use "@pythnetwork/component-library/theme";

.copyButton {
margin: -#{theme.spacing(0.5)} -0.5em;
margin: -#{theme.spacing(0.5)} -#{theme.spacing(1)};
display: inline-block;
white-space: nowrap;
border-radius: theme.border-radius("md");
padding: theme.spacing(0.5) 0.5em;
border: none;
padding: theme.spacing(0.5) theme.spacing(1);
background: none;
cursor: pointer;
transition: background-color 100ms linear;
outline: none;
transition-property: background-color, color, border-color, outline-color;
transition-duration: 100ms;
transition-timing-function: linear;
border: 1px solid transparent;
outline-offset: 0;
outline: theme.spacing(1) solid transparent;

.iconContainer {
position: relative;
top: 0.125em;
margin-left: theme.spacing(1);
display: inline-block;

.copyIconContainer {
.copyIcon {
opacity: 0.5;
transition: opacity 100ms linear;

.copyIcon {
width: 1em;
height: 1em;
}

.copyIconLabel {
@include theme.sr-only;
}
width: 1em;
height: 1em;
}

.checkIcon {
Expand All @@ -50,12 +46,12 @@
}

&[data-focus-visible] {
outline: 1px solid currentcolor;
outline-offset: theme.spacing(1);
border-color: theme.color("focus");
outline-color: theme.color("focus-dim");
}

&[data-is-copied] .iconContainer {
.copyIconContainer {
.copyIcon {
opacity: 0;
}

Expand Down
5 changes: 1 addition & 4 deletions apps/insights/src/components/CopyButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,7 @@ export const CopyButton = ({
{typeof children === "function" ? children(...args) : children}
</span>
<span className={styles.iconContainer}>
<span className={styles.copyIconContainer}>
<Copy className={styles.copyIcon} />
<div className={styles.copyIconLabel}>Copy to clipboard</div>
</span>
<Copy className={styles.copyIcon} />
<Check className={styles.checkIcon} />
</span>
</>
Expand Down
1 change: 1 addition & 0 deletions apps/insights/src/components/H1/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
.h1 {
font-size: theme.font-size("2xl");
font-weight: theme.font-weight("medium");
margin: 0;
}
27 changes: 27 additions & 0 deletions apps/insights/src/components/LivePrices/index.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
@use "@pythnetwork/component-library/theme";

.price {
transition: color 100ms linear;

&[data-direction="up"] {
color: theme.color("states", "success", "base");
}

&[data-direction="down"] {
color: theme.color("states", "error", "base");
}
}

.confidence {
display: flex;
flex-flow: row nowrap;
gap: theme.spacing(2);
align-items: center;

.plusMinus {
width: theme.spacing(4);
height: theme.spacing(4);
display: inline-block;
color: theme.color("muted");
}
}
202 changes: 202 additions & 0 deletions apps/insights/src/components/LivePrices/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"use client";

import { PlusMinus } from "@phosphor-icons/react/dist/ssr/PlusMinus";
import { useLogger } from "@pythnetwork/app-logger";
import { Skeleton } from "@pythnetwork/component-library/Skeleton";
import { useMap } from "@react-hookz/web";
import { PublicKey } from "@solana/web3.js";
import {
type ComponentProps,
use,
createContext,
useEffect,
useCallback,
useState,
} from "react";
import { useNumberFormatter } from "react-aria";

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

export const SKELETON_WIDTH = 20;

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

type Price = {
price: number;
direction: ChangeDirection;
confidence: number;
};

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

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

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

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

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

useEffect(() => {
addSubscription(account);
return () => {
removeSubscription(account);
};
}, [addSubscription, removeSubscription, account]);

return priceData.get(account);
};

export const LivePrice = ({ account }: { account: string }) => {
const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
const price = useLivePrice(account);

return price === undefined ? (
<Skeleton width={SKELETON_WIDTH} />
) : (
<span className={styles.price} data-direction={price.direction}>
{numberFormatter.format(price.price)}
</span>
);
};

export const LiveConfidence = ({ account }: { account: string }) => {
const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
const price = useLivePrice(account);

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

const usePriceData = () => {
const feedSubscriptions = useMap<string, number>([]);
const [feedKeys, setFeedKeys] = useState<string[]>([]);
const priceData = useMap<string, Price>([]);
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) {
client
.getAssetPricesFromAccounts(
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: price.aggregate.price,
direction: "flat",
confidence: price.aggregate.confidence,
});
}
}
})
.catch((error: unknown) => {
logger.error("Failed to fetch initial prices", error);
});
}

// Then, we create a subscription to update prices live.
const connection = subscribe(
feedKeys.map((key) => new PublicKey(key)),
({ price_account }, { aggregate }) => {
if (price_account) {
const prevPrice = priceData.get(price_account)?.price;
priceData.set(price_account, {
price: aggregate.price,
direction: getChangeDirection(prevPrice, aggregate.price),
confidence: aggregate.confidence,
});
}
},
);

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]);

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),
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,
): ChangeDirection => {
if (prevPrice === undefined || prevPrice === price) {
return "flat";
} else if (prevPrice < price) {
return "up";
} else {
return "down";
}
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
@use "@pythnetwork/component-library/theme";

.epochSelect {
.drawerTitle {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: theme.spacing(2);
gap: theme.spacing(3);
}
Loading
Loading