Skip to content

Commit

Permalink
feat(insights): finish price feeds index
Browse files Browse the repository at this point in the history
  • Loading branch information
cprussin committed Nov 30, 2024
1 parent 7259cb2 commit f2ca681
Show file tree
Hide file tree
Showing 81 changed files with 4,597 additions and 2,927 deletions.
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

0 comments on commit f2ca681

Please sign in to comment.