Prechádzať zdrojové kódy

feat(insights): initial version of price chart

Connor Prussin 9 mesiacov pred
rodič
commit
e2a78318d5
25 zmenil súbory, kde vykonal 680 pridanie a 230 odobranie
  1. 1 0
      apps/insights/package.json
  2. 14 0
      apps/insights/src/app/historical-prices/route.ts
  3. 1 1
      apps/insights/src/app/price-feeds/[slug]/page.ts
  4. 3 2
      apps/insights/src/components/ChangePercent/index.tsx
  5. 10 178
      apps/insights/src/components/LivePrices/index.tsx
  6. 1 1
      apps/insights/src/components/PriceComponentDrawer/index.tsx
  7. 3 1
      apps/insights/src/components/PriceFeed/chart-page.module.scss
  8. 31 0
      apps/insights/src/components/PriceFeed/chart-page.tsx
  9. 281 8
      apps/insights/src/components/PriceFeed/chart.tsx
  10. 1 1
      apps/insights/src/components/PriceFeed/publishers-card.tsx
  11. 15 0
      apps/insights/src/components/PriceFeed/theme.module.scss
  12. 3 3
      apps/insights/src/components/PriceFeedChangePercent/index.tsx
  13. 1 1
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  14. 1 1
      apps/insights/src/components/Publisher/price-feeds-card.tsx
  15. 1 10
      apps/insights/src/components/PublisherTag/index.tsx
  16. 1 1
      apps/insights/src/components/Publishers/publishers-card.tsx
  17. 2 2
      apps/insights/src/components/Root/index.tsx
  18. 0 0
      apps/insights/src/hooks/use-data.ts
  19. 178 0
      apps/insights/src/hooks/use-live-price-data.tsx
  20. 0 0
      apps/insights/src/hooks/use-query-param-filter-pagination.ts
  21. 9 0
      apps/insights/src/omit-keys.ts
  22. 30 0
      apps/insights/src/services/clickhouse.ts
  23. 4 1
      packages/component-library/src/theme.scss
  24. 88 19
      pnpm-lock.yaml
  25. 1 0
      pnpm-workspace.yaml

+ 1 - 0
apps/insights/package.json

@@ -35,6 +35,7 @@
     "clsx": "catalog:",
     "cryptocurrency-icons": "catalog:",
     "dnum": "catalog:",
+    "lightweight-charts": "catalog:",
     "motion": "catalog:",
     "next": "catalog:",
     "next-themes": "catalog:",

+ 14 - 0
apps/insights/src/app/historical-prices/route.ts

@@ -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 });
+  }
+}

+ 1 - 1
apps/insights/src/app/price-feeds/[slug]/page.ts

@@ -1 +1 @@
-export { Chart as default } from "../../../components/PriceFeed/chart";
+export { ChartPage as default } from "../../../components/PriceFeed/chart-page";

+ 3 - 2
apps/insights/src/components/ChangePercent/index.tsx

@@ -1,5 +1,6 @@
 import type { ComponentProps } from "react";
 
+import { omitKeys } from "../../omit-keys";
 import { ChangeValue } from "../ChangeValue";
 import { FormattedNumber } from "../FormattedNumber";
 
@@ -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}

+ 10 - 178
apps/insights/src/components/LivePrices/index.tsx

@@ -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,
@@ -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} />
   );
@@ -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} />
@@ -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} />;
 };
 
@@ -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}>
@@ -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",
@@ -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;
 };
@@ -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,

+ 1 - 1
apps/insights/src/components/PriceComponentDrawer/index.tsx

@@ -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";

+ 3 - 1
apps/insights/src/components/PriceFeed/chart.module.scss → apps/insights/src/components/PriceFeed/chart-page.module.scss

@@ -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 - 0
apps/insights/src/components/PriceFeed/chart-page.tsx

@@ -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()
+  );
+};

+ 281 - 8
apps/insights/src/components/PriceFeed/chart.tsx

@@ -1,11 +1,284 @@
-import { Card } from "@pythnetwork/component-library/Card";
+"use client";
 
-import styles from "./chart.module.scss";
+import { useLogger } from "@pythnetwork/app-logger";
+import { useResizeObserver } from "@react-hookz/web";
+import {
+  type IChartApi,
+  type ISeriesApi,
+  type UTCTimestamp,
+  LineStyle,
+  createChart,
+} from "lightweight-charts";
+import { useTheme } from "next-themes";
+import { type RefObject, useEffect, useRef, useCallback } from "react";
+import { z } from "zod";
 
-export const Chart = () => (
-  <Card title="Chart" className={styles.chartCard}>
-    <div className={styles.chart}>
-      <h1>This is a chart</h1>
-    </div>
-  </Card>
+import theme from "./theme.module.scss";
+import { useLivePriceData } from "../../hooks/use-live-price-data";
+
+type Props = {
+  symbol: string;
+  feedId: string;
+};
+
+export const Chart = ({ symbol, feedId }: Props) => {
+  const chartContainerRef = useChart(symbol, feedId);
+
+  return (
+    <div style={{ width: "100%", height: "100%" }} ref={chartContainerRef} />
+  );
+};
+
+const useChart = (symbol: string, feedId: string) => {
+  const { chartContainerRef, chartRef } = useChartElem(symbol, feedId);
+  useChartResize(chartContainerRef, chartRef);
+  useChartColors(chartRef);
+  return chartContainerRef;
+};
+
+const useChartElem = (symbol: string, feedId: string) => {
+  const logger = useLogger();
+  const { current } = useLivePriceData(feedId);
+  const chartContainerRef = useRef<HTMLDivElement | null>(null);
+  const chartRef = useRef<ChartRefContents | undefined>(undefined);
+  const earliestDateRef = useRef<bigint | undefined>(undefined);
+  const isBackfilling = useRef(false);
+
+  const backfillData = useCallback(() => {
+    if (!isBackfilling.current && earliestDateRef.current) {
+      isBackfilling.current = true;
+      const url = new URL("/historical-prices", window.location.origin);
+      url.searchParams.set("symbol", symbol);
+      url.searchParams.set("until", earliestDateRef.current.toString());
+      fetch(url)
+        .then(async (data) => historicalDataSchema.parse(await data.json()))
+        .then((data) => {
+          const firstPoint = data[0];
+          if (firstPoint) {
+            earliestDateRef.current = BigInt(firstPoint.timestamp);
+          }
+          if (
+            chartRef.current &&
+            chartRef.current.resolution === Resolution.Tick
+          ) {
+            const convertedData = data.map(
+              ({ timestamp, price, confidence }) => ({
+                time: getLocalTimestamp(new Date(timestamp * 1000)),
+                price,
+                confidence,
+              }),
+            );
+            chartRef.current.price.setData([
+              ...convertedData.map(({ time, price }) => ({
+                time,
+                value: price,
+              })),
+              ...chartRef.current.price.data(),
+            ]);
+            chartRef.current.confidenceHigh.setData([
+              ...convertedData.map(({ time, price, confidence }) => ({
+                time,
+                value: price + confidence,
+              })),
+              ...chartRef.current.confidenceHigh.data(),
+            ]);
+            chartRef.current.confidenceLow.setData([
+              ...convertedData.map(({ time, price, confidence }) => ({
+                time,
+                value: price - confidence,
+              })),
+              ...chartRef.current.confidenceLow.data(),
+            ]);
+          }
+          isBackfilling.current = false;
+        })
+        .catch((error: unknown) => {
+          logger.error("Error fetching historical prices", error);
+        });
+    }
+  }, [logger, symbol]);
+
+  useEffect(() => {
+    const chartElem = chartContainerRef.current;
+    if (chartElem === null) {
+      return;
+    } else {
+      const chart = createChart(chartElem, {
+        layout: {
+          attributionLogo: false,
+          background: { color: "transparent" },
+        },
+        timeScale: {
+          timeVisible: true,
+          secondsVisible: true,
+        },
+      });
+
+      const price = chart.addLineSeries({ priceFormat });
+
+      chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
+        if (
+          range && // if (range.to - range.from > 1000) {
+          //   console.log("DECREASE RESOLUTION");
+          // } else if (range.to - range.from < 100) {
+          //   console.log("INCREASE RESOLUTION");
+          // } else if (range.from < 10) {
+          range.from < 10
+        ) {
+          backfillData();
+        }
+      });
+
+      chartRef.current = {
+        resolution: Resolution.Tick,
+        chart,
+        confidenceHigh: chart.addLineSeries(confidenceConfig),
+        confidenceLow: chart.addLineSeries(confidenceConfig),
+        price,
+      };
+      return () => {
+        chart.remove();
+      };
+    }
+  }, [backfillData]);
+
+  useEffect(() => {
+    if (current && chartRef.current) {
+      if (!earliestDateRef.current) {
+        earliestDateRef.current = current.timestamp;
+      }
+      const { price, confidence } = current.aggregate;
+      const time = getLocalTimestamp(
+        new Date(Number(current.timestamp * 1000n)),
+      );
+      if (chartRef.current.resolution === Resolution.Tick) {
+        chartRef.current.price.update({ time, value: price });
+        chartRef.current.confidenceHigh.update({
+          time,
+          value: price + confidence,
+        });
+        chartRef.current.confidenceLow.update({
+          time,
+          value: price - confidence,
+        });
+      }
+    }
+  }, [current]);
+
+  return { chartRef, chartContainerRef };
+};
+
+enum Resolution {
+  Tick,
+  Minute,
+  Hour,
+  Day,
+}
+
+type ChartRefContents = {
+  chart: IChartApi;
+} & (
+  | {
+      resolution: Resolution.Tick;
+      confidenceHigh: ISeriesApi<"Line">;
+      confidenceLow: ISeriesApi<"Line">;
+      price: ISeriesApi<"Line">;
+    }
+  | {
+      resolution: Exclude<Resolution, Resolution.Tick>;
+      series: ISeriesApi<"Candlestick">;
+    }
+);
+
+const historicalDataSchema = z.array(
+  z.strictObject({
+    timestamp: z.number(),
+    price: z.number(),
+    confidence: z.number(),
+  }),
 );
+
+const priceFormat = {
+  type: "price",
+  precision: 5,
+  minMove: 0.000_01,
+} as const;
+
+const confidenceConfig = {
+  priceFormat,
+  lineStyle: LineStyle.Dashed,
+  lineWidth: 1,
+} as const;
+
+const useChartResize = (
+  chartContainerRef: RefObject<HTMLDivElement | null>,
+  chartRef: RefObject<ChartRefContents | undefined>,
+) => {
+  useResizeObserver(chartContainerRef.current, ({ contentRect }) => {
+    const { chart } = chartRef.current ?? {};
+    if (chart) {
+      chart.applyOptions({ width: contentRect.width });
+    }
+  });
+};
+
+const useChartColors = (chartRef: RefObject<ChartRefContents | undefined>) => {
+  const { resolvedTheme } = useTheme();
+  useEffect(() => {
+    if (chartRef.current && resolvedTheme) {
+      applyColors(chartRef.current, resolvedTheme);
+    }
+  }, [resolvedTheme, chartRef]);
+};
+
+const applyColors = ({ chart, ...series }: ChartRefContents, theme: string) => {
+  const colors = getColors(theme);
+  chart.applyOptions({
+    grid: {
+      horzLines: {
+        color: colors.border,
+      },
+      vertLines: {
+        color: colors.border,
+      },
+    },
+    layout: {
+      textColor: colors.muted,
+    },
+    timeScale: {
+      borderColor: colors.muted,
+    },
+    rightPriceScale: {
+      borderColor: colors.muted,
+    },
+  });
+  if (series.resolution === Resolution.Tick) {
+    series.confidenceHigh.applyOptions({
+      color: colors.chartNeutral,
+    });
+    series.confidenceLow.applyOptions({
+      color: colors.chartNeutral,
+    });
+    series.price.applyOptions({
+      color: colors.chartPrimary,
+    });
+  }
+};
+
+const getColors = (resolvedTheme: string) => ({
+  border: theme[`border-${resolvedTheme}`] ?? "red",
+  muted: theme[`muted-${resolvedTheme}`] ?? "",
+  chartNeutral: theme[`chart-series-neutral-${resolvedTheme}`] ?? "",
+  chartPrimary: theme[`chart-series-primary-${resolvedTheme}`] ?? "",
+});
+
+const getLocalTimestamp = (date: Date): UTCTimestamp =>
+  (Date.UTC(
+    date.getFullYear(),
+    date.getMonth(),
+    date.getDate(),
+    date.getHours(),
+    date.getMinutes(),
+    date.getSeconds(),
+    date.getMilliseconds(),
+  ) / 1000) as UTCTimestamp;

+ 1 - 1
apps/insights/src/components/PriceFeed/publishers-card.tsx

@@ -16,9 +16,9 @@ import { type ReactNode, Suspense, useMemo, useCallback } from "react";
 import { useFilter, useCollator } from "react-aria";
 
 import styles from "./publishers-card.module.scss";
+import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
 import { Cluster } from "../../services/pyth";
 import { Status as StatusType } from "../../status";
-import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
 import { FormattedNumber } from "../FormattedNumber";
 import { NoResults } from "../NoResults";
 import { PriceComponentDrawer } from "../PriceComponentDrawer";

+ 15 - 0
apps/insights/src/components/PriceFeed/theme.module.scss

@@ -0,0 +1,15 @@
+@use "@pythnetwork/component-library/theme";
+
+// stylelint-disable property-no-unknown
+:export {
+  border-light: theme.pallette-color("stone", 300);
+  border-dark: theme.pallette-color("steel", 600);
+  muted-light: theme.pallette-color("stone", 700);
+  muted-dark: theme.pallette-color("steel", 300);
+  chart-series-primary-light: theme.pallette-color("violet", 500);
+  chart-series-primary-dark: theme.pallette-color("violet", 400);
+  chart-series-neutral-light: theme.pallette-color("stone", 500);
+  chart-series-neutral-dark: theme.pallette-color("steel", 300);
+}
+
+// stylelint-enable property-no-unknown

+ 3 - 3
apps/insights/src/components/PriceFeedChangePercent/index.tsx

@@ -3,9 +3,9 @@
 import { type ComponentProps, createContext, use } from "react";
 import { z } from "zod";
 
-import { StateType, useData } from "../../use-data";
+import { StateType, useData } from "../../hooks/use-data";
+import { useLivePriceData } from "../../hooks/use-live-price-data";
 import { ChangePercent } from "../ChangePercent";
-import { useLivePrice } from "../LivePrices";
 
 const ONE_SECOND_IN_MS = 1000;
 const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
@@ -105,7 +105,7 @@ const PriceFeedChangePercentLoaded = ({
   priorPrice,
   feedKey,
 }: PriceFeedChangePercentLoadedProps) => {
-  const { current } = useLivePrice(feedKey);
+  const { current } = useLivePriceData(feedKey);
 
   return current === undefined ? (
     <ChangePercent className={className} isLoading />

+ 1 - 1
apps/insights/src/components/PriceFeeds/price-feeds-card.tsx

@@ -16,7 +16,7 @@ import { useQueryState, parseAsString } from "nuqs";
 import { type ReactNode, Suspense, useCallback, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
-import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
+import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
 import { FeedKey } from "../FeedKey";
 import {
   SKELETON_WIDTH,

+ 1 - 1
apps/insights/src/components/Publisher/price-feeds-card.tsx

@@ -14,9 +14,9 @@ import { useFilter, useCollator } from "react-aria";
 
 import { useSelectPriceFeed } from "./price-feed-drawer-provider";
 import styles from "./price-feeds-card.module.scss";
+import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
 import { Cluster } from "../../services/pyth";
 import { Status as StatusType } from "../../status";
-import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
 import { FormattedNumber } from "../FormattedNumber";
 import { NoResults } from "../NoResults";
 import { PriceFeedTag } from "../PriceFeedTag";

+ 1 - 10
apps/insights/src/components/PublisherTag/index.tsx

@@ -4,6 +4,7 @@ import clsx from "clsx";
 import type { ComponentProps, ReactNode } from "react";
 
 import styles from "./index.module.scss";
+import { omitKeys } from "../../omit-keys";
 import { PublisherKey } from "../PublisherKey";
 
 type Props = ComponentProps<"div"> & { compact?: boolean | undefined } & (
@@ -69,13 +70,3 @@ const Contents = (props: Props) => {
     return <PublisherKey publisherKey={props.publisherKey} size="sm" />;
   }
 };
-
-const omitKeys = <T extends Record<string, unknown>>(
-  obj: T,
-  keys: string[],
-) => {
-  const omitSet = new Set(keys);
-  return Object.fromEntries(
-    Object.entries(obj).filter(([key]) => !omitSet.has(key)),
-  );
-};

+ 1 - 1
apps/insights/src/components/Publishers/publishers-card.tsx

@@ -13,7 +13,7 @@ import {
 import { type ReactNode, Suspense, useMemo } from "react";
 import { useFilter, useCollator } from "react-aria";
 
-import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination";
+import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
 import { NoResults } from "../NoResults";
 import { PublisherTag } from "../PublisherTag";
 import { Ranking } from "../Ranking";

+ 2 - 2
apps/insights/src/components/Root/index.tsx

@@ -15,9 +15,9 @@ import {
   AMPLITUDE_API_KEY,
 } from "../../config/server";
 import { toHex } from "../../hex";
+import { LivePriceDataProvider } from "../../hooks/use-live-price-data";
 import { getPublishers } from "../../services/clickhouse";
 import { Cluster, getData } from "../../services/pyth";
-import { LivePricesProvider } from "../LivePrices";
 import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PublisherIcon } from "../PublisherIcon";
 
@@ -36,7 +36,7 @@ export const Root = async ({ children }: Props) => {
       amplitudeApiKey={AMPLITUDE_API_KEY}
       googleAnalyticsId={GOOGLE_ANALYTICS_ID}
       enableAccessibilityReporting={ENABLE_ACCESSIBILITY_REPORTING}
-      providers={[NuqsAdapter, LivePricesProvider]}
+      providers={[NuqsAdapter, LivePriceDataProvider]}
       className={styles.root}
     >
       <SearchDialogProvider

+ 0 - 0
apps/insights/src/use-data.ts → apps/insights/src/hooks/use-data.ts


+ 178 - 0
apps/insights/src/hooks/use-live-price-data.tsx

@@ -0,0 +1,178 @@
+"use client";
+
+import { useLogger } from "@pythnetwork/app-logger";
+import type { PriceData } from "@pythnetwork/client";
+import { useMap } from "@react-hookz/web";
+import { PublicKey } from "@solana/web3.js";
+import {
+  type ComponentProps,
+  use,
+  createContext,
+  useEffect,
+  useCallback,
+  useState,
+  useMemo,
+} from "react";
+
+import {
+  Cluster,
+  subscribe,
+  getAssetPricesFromAccounts,
+} from "../services/pyth";
+
+export const SKELETON_WIDTH = 20;
+
+const LivePriceDataContext = createContext<
+  ReturnType<typeof usePriceData> | undefined
+>(undefined);
+
+type LivePriceDataProviderProps = Omit<
+  ComponentProps<typeof LivePriceDataContext>,
+  "value"
+>;
+
+export const LivePriceDataProvider = (props: LivePriceDataProviderProps) => {
+  const priceData = usePriceData();
+
+  return <LivePriceDataContext value={priceData} {...props} />;
+};
+
+export const useLivePriceData = (feedKey: string) => {
+  const { priceData, prevPriceData, addSubscription, removeSubscription } =
+    useLivePriceDataContext();
+
+  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 } = useLivePriceData(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),
+    ),
+  };
+};
+
+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 useLivePriceDataContext = () => {
+  const prices = use(LivePriceDataContext);
+  if (prices === undefined) {
+    throw new LivePriceDataProviderNotInitializedError();
+  }
+  return prices;
+};
+
+class LivePriceDataProviderNotInitializedError extends Error {
+  constructor() {
+    super("This component must be a child of <LivePriceDataProvider>");
+    this.name = "LivePriceDataProviderNotInitializedError";
+  }
+}

+ 0 - 0
apps/insights/src/use-query-param-filter-pagination.ts → apps/insights/src/hooks/use-query-param-filter-pagination.ts


+ 9 - 0
apps/insights/src/omit-keys.ts

@@ -0,0 +1,9 @@
+export const omitKeys = <T extends Record<string, unknown>>(
+  obj: T,
+  keys: string[],
+) => {
+  const omitSet = new Set(keys);
+  return Object.fromEntries(
+    Object.entries(obj).filter(([key]) => !omitSet.has(key)),
+  );
+};

+ 30 - 0
apps/insights/src/services/clickhouse.ts

@@ -255,6 +255,36 @@ export const getPublisherMedianScoreHistory = cache(
   },
 );
 
+export const getHistoricalPrices = cache(
+  async (symbol: string, until: string) =>
+    safeQuery(
+      z.array(
+        z.strictObject({
+          timestamp: z.number(),
+          price: z.number(),
+          confidence: z.number(),
+        }),
+      ),
+      {
+        query: `
+          SELECT toUnixTimestamp(time) AS timestamp, avg(price) AS price, avg(confidence) AS confidence
+          FROM prices
+          WHERE cluster = 'pythnet'
+          AND symbol = {symbol: String}
+          AND version = 2
+          AND time > fromUnixTimestamp(toInt64({until: String})) - INTERVAL 5 MINUTE
+          AND time < fromUnixTimestamp(toInt64({until: String}))
+          AND publisher = ''
+          GROUP BY time
+          ORDER BY time ASC
+        `,
+        query_params: { symbol, until },
+      },
+    ),
+  ["price-history"],
+  {},
+);
+
 const safeQuery = async <Output, Def extends ZodTypeDef, Input>(
   schema: ZodSchema<Output, Def, Input>,
   query: Omit<Parameters<typeof client.query>[0], "format">,

+ 4 - 1
packages/component-library/src/theme.scss

@@ -723,7 +723,10 @@ $max-width: 96rem;
 
 @mixin max-width {
   margin: 0 auto;
-  max-width: $max-width;
+  max-width: min(
+    $max-width,
+    calc(200vw - spacing(12) - 100% - var(--scrollbar-width))
+  );
   padding: 0 spacing(6);
   box-sizing: content-box;
 }

+ 88 - 19
pnpm-lock.yaml

@@ -108,6 +108,9 @@ catalogs:
     jest:
       specifier: 29.7.0
       version: 29.7.0
+    lightweight-charts:
+      specifier: ^4.2.3
+      version: 4.2.3
     modern-normalize:
       specifier: 3.0.1
       version: 3.0.1
@@ -298,7 +301,7 @@ importers:
         version: 4.9.1
       '@cprussin/eslint-config':
         specifier: 'catalog:'
-        version: 3.0.0(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(jest@29.7.0(@types/node@20.14.15)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4)))(jiti@1.21.0)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4))(typescript@5.5.4)
+        version: 3.0.0(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(jest@29.7.0(@types/node@20.14.15)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4)))(jiti@1.21.0)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4))(typescript@5.5.4)
       '@cprussin/jest-config':
         specifier: 'catalog:'
         version: 1.4.1(@babel/core@7.25.8)(@jest/transform@29.7.0)(@jest/types@29.6.3)(@types/node@20.14.15)(babel-jest@29.7.0(@babel/core@7.25.8))(bufferutil@4.0.8)(eslint@9.13.0(jiti@1.21.0))(sass@1.80.7)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4))(utf-8-validate@5.0.10)
@@ -398,7 +401,7 @@ importers:
     devDependencies:
       '@cprussin/eslint-config':
         specifier: 'catalog:'
-        version: 3.0.0(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(jest@29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(jiti@1.21.0)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))(typescript@5.6.3)
+        version: 3.0.0(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(jest@29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(jiti@1.21.0)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))(typescript@5.6.3)
       '@cprussin/jest-config':
         specifier: 'catalog:'
         version: 1.4.1(@babel/core@7.25.8)(@jest/transform@29.7.0)(@jest/types@29.6.3)(@types/node@22.8.2)(babel-jest@29.7.0(@babel/core@7.25.8))(bufferutil@4.0.8)(esbuild@0.22.0)(eslint@9.13.0(jiti@1.21.0))(sass@1.80.7)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))(utf-8-validate@6.0.4)
@@ -541,6 +544,9 @@ importers:
       dnum:
         specifier: 'catalog:'
         version: 2.14.0
+      lightweight-charts:
+        specifier: 'catalog:'
+        version: 4.2.3
       motion:
         specifier: 'catalog:'
         version: 11.14.4(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -13092,6 +13098,9 @@ packages:
   fake-merkle-patricia-tree@1.0.1:
     resolution: {integrity: sha512-Tgq37lkc9pUIgIKw5uitNUKcgcYL3R6JvXtKQbOf/ZSavXbidsksgp/pAY6p//uhw0I4yoMsvTSovvVIsk/qxA==}
 
+  fancy-canvas@2.1.0:
+    resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==}
+
   fast-base64-decode@1.0.0:
     resolution: {integrity: sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==}
 
@@ -15247,6 +15256,9 @@ packages:
   lighthouse-logger@1.4.2:
     resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
 
+  lightweight-charts@4.2.3:
+    resolution: {integrity: sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==}
+
   lilconfig@2.1.0:
     resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
     engines: {node: '>=10'}
@@ -22998,7 +23010,7 @@ snapshots:
     transitivePeerDependencies:
       - debug
 
-  '@cprussin/eslint-config@3.0.0(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(jest@29.7.0(@types/node@20.14.15)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4)))(jiti@1.21.0)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4))(typescript@5.5.4)':
+  '@cprussin/eslint-config@3.0.0(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(jest@29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(jiti@1.21.0)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))(typescript@5.6.3)':
     dependencies:
       '@babel/core': 7.25.8
       '@babel/eslint-parser': 7.24.7(@babel/core@7.25.8)(eslint@9.13.0(jiti@1.21.0))
@@ -23010,22 +23022,22 @@ snapshots:
       eslint: 9.13.0(jiti@1.21.0)
       eslint-config-prettier: 9.1.0(eslint@9.13.0(jiti@1.21.0))
       eslint-config-turbo: 2.2.3(eslint@9.13.0(jiti@1.21.0))
-      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))
-      eslint-plugin-jest: 28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(jest@29.7.0(@types/node@20.14.15)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4)))(typescript@5.5.4)
+      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint@9.13.0(jiti@1.21.0))
+      eslint-plugin-jest: 28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint@9.13.0(jiti@1.21.0))(jest@29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(typescript@5.6.3)
       eslint-plugin-jest-dom: 5.4.0(@testing-library/dom@10.4.0)(eslint@9.13.0(jiti@1.21.0))
       eslint-plugin-jsonc: 2.16.0(eslint@9.13.0(jiti@1.21.0))
       eslint-plugin-jsx-a11y: 6.8.0(eslint@9.13.0(jiti@1.21.0))
       eslint-plugin-n: 17.9.0(eslint@9.13.0(jiti@1.21.0))
       eslint-plugin-react: 7.34.2(eslint@9.13.0(jiti@1.21.0))
       eslint-plugin-react-hooks: 4.6.2(eslint@9.13.0(jiti@1.21.0))
-      eslint-plugin-storybook: 0.8.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
-      eslint-plugin-tailwindcss: 3.17.3(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4)))
-      eslint-plugin-testing-library: 6.2.2(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
+      eslint-plugin-storybook: 0.8.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3)
+      eslint-plugin-tailwindcss: 3.17.3(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))
+      eslint-plugin-testing-library: 6.2.2(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3)
       eslint-plugin-tsdoc: 0.3.0
       eslint-plugin-unicorn: 53.0.0(eslint@9.13.0(jiti@1.21.0))
       globals: 15.6.0
-      tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4))
-      typescript-eslint: 7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
+      tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))
+      typescript-eslint: 7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3)
     transitivePeerDependencies:
       - '@testing-library/dom'
       - '@typescript-eslint/eslint-plugin'
@@ -23078,6 +23090,46 @@ snapshots:
       - ts-node
       - typescript
 
+  '@cprussin/eslint-config@3.0.0(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(jest@29.7.0(@types/node@20.14.15)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4)))(jiti@1.21.0)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4))(typescript@5.5.4)':
+    dependencies:
+      '@babel/core': 7.25.8
+      '@babel/eslint-parser': 7.24.7(@babel/core@7.25.8)(eslint@9.13.0(jiti@1.21.0))
+      '@babel/plugin-syntax-import-assertions': 7.24.7(@babel/core@7.25.8)
+      '@eslint/compat': 1.1.0
+      '@eslint/eslintrc': 3.1.0
+      '@eslint/js': 9.13.0
+      '@next/eslint-plugin-next': 14.2.3
+      eslint: 9.13.0(jiti@1.21.0)
+      eslint-config-prettier: 9.1.0(eslint@9.13.0(jiti@1.21.0))
+      eslint-config-turbo: 2.2.3(eslint@9.13.0(jiti@1.21.0))
+      eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))
+      eslint-plugin-jest: 28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(jest@29.7.0(@types/node@20.14.15)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4)))(typescript@5.5.4)
+      eslint-plugin-jest-dom: 5.4.0(@testing-library/dom@10.4.0)(eslint@9.13.0(jiti@1.21.0))
+      eslint-plugin-jsonc: 2.16.0(eslint@9.13.0(jiti@1.21.0))
+      eslint-plugin-jsx-a11y: 6.8.0(eslint@9.13.0(jiti@1.21.0))
+      eslint-plugin-n: 17.9.0(eslint@9.13.0(jiti@1.21.0))
+      eslint-plugin-react: 7.34.2(eslint@9.13.0(jiti@1.21.0))
+      eslint-plugin-react-hooks: 4.6.2(eslint@9.13.0(jiti@1.21.0))
+      eslint-plugin-storybook: 0.8.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
+      eslint-plugin-tailwindcss: 3.17.3(tailwindcss@3.4.14(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4)))
+      eslint-plugin-testing-library: 6.2.2(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
+      eslint-plugin-tsdoc: 0.3.0
+      eslint-plugin-unicorn: 53.0.0(eslint@9.13.0(jiti@1.21.0))
+      globals: 15.6.0
+      tailwindcss: 3.4.14(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4))
+      typescript-eslint: 7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
+    transitivePeerDependencies:
+      - '@testing-library/dom'
+      - '@typescript-eslint/eslint-plugin'
+      - '@typescript-eslint/parser'
+      - eslint-import-resolver-typescript
+      - eslint-import-resolver-webpack
+      - jest
+      - jiti
+      - supports-color
+      - ts-node
+      - typescript
+
   '@cprussin/eslint-config@3.0.0(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(jest@29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.5.4)))(jiti@1.21.0)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.5.4))(typescript@5.5.4)':
     dependencies:
       '@babel/core': 7.25.8
@@ -37670,11 +37722,11 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  eslint-module-utils@2.8.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@1.21.0)):
+  eslint-module-utils@2.8.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@1.21.0)):
     dependencies:
       debug: 3.2.7
     optionalDependencies:
-      '@typescript-eslint/parser': 7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
+      '@typescript-eslint/parser': 7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3)
       eslint: 9.13.0(jiti@1.21.0)
       eslint-import-resolver-node: 0.3.9
     transitivePeerDependencies:
@@ -37707,7 +37759,7 @@ snapshots:
       eslint: 9.13.0(jiti@1.21.0)
       eslint-compat-utils: 0.5.1(eslint@9.13.0(jiti@1.21.0))
 
-  eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0)):
+  eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint@9.13.0(jiti@1.21.0)):
     dependencies:
       array-includes: 3.1.8
       array.prototype.findlastindex: 1.2.5
@@ -37717,7 +37769,7 @@ snapshots:
       doctrine: 2.1.0
       eslint: 9.13.0(jiti@1.21.0)
       eslint-import-resolver-node: 0.3.9
-      eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@1.21.0))
+      eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@1.21.0))
       hasown: 2.0.2
       is-core-module: 2.15.1
       is-glob: 4.0.3
@@ -37728,7 +37780,7 @@ snapshots:
       semver: 6.3.1
       tsconfig-paths: 3.15.0
     optionalDependencies:
-      '@typescript-eslint/parser': 7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
+      '@typescript-eslint/parser': 7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3)
     transitivePeerDependencies:
       - eslint-import-resolver-typescript
       - eslint-import-resolver-webpack
@@ -37823,13 +37875,13 @@ snapshots:
     optionalDependencies:
       '@testing-library/dom': 10.4.0
 
-  eslint-plugin-jest@28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(jest@29.7.0(@types/node@20.14.15)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4)))(typescript@5.5.4):
+  eslint-plugin-jest@28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint@9.13.0(jiti@1.21.0))(jest@29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3)))(typescript@5.6.3):
     dependencies:
-      '@typescript-eslint/utils': 7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
+      '@typescript-eslint/utils': 7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3)
       eslint: 9.13.0(jiti@1.21.0)
     optionalDependencies:
-      '@typescript-eslint/eslint-plugin': 7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
-      jest: 29.7.0(@types/node@20.14.15)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4))
+      '@typescript-eslint/eslint-plugin': 7.13.1(@typescript-eslint/parser@7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3))(eslint@9.13.0(jiti@1.21.0))(typescript@5.6.3)
+      jest: 29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.6.3))
     transitivePeerDependencies:
       - supports-color
       - typescript
@@ -37845,6 +37897,17 @@ snapshots:
       - supports-color
       - typescript
 
+  eslint-plugin-jest@28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(jest@29.7.0(@types/node@20.14.15)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4)))(typescript@5.5.4):
+    dependencies:
+      '@typescript-eslint/utils': 7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
+      eslint: 9.13.0(jiti@1.21.0)
+    optionalDependencies:
+      '@typescript-eslint/eslint-plugin': 7.13.1(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
+      jest: 29.7.0(@types/node@20.14.15)(ts-node@10.9.2(@types/node@20.14.15)(typescript@5.5.4))
+    transitivePeerDependencies:
+      - supports-color
+      - typescript
+
   eslint-plugin-jest@28.6.0(@typescript-eslint/eslint-plugin@7.13.1(@typescript-eslint/parser@8.14.0(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4))(eslint@9.13.0(jiti@1.21.0))(jest@29.7.0(@types/node@22.8.2)(ts-node@10.9.2(@types/node@22.8.2)(typescript@5.5.4)))(typescript@5.5.4):
     dependencies:
       '@typescript-eslint/utils': 7.13.1(eslint@9.13.0(jiti@1.21.0))(typescript@5.5.4)
@@ -38845,6 +38908,8 @@ snapshots:
     dependencies:
       checkpoint-store: 1.1.0
 
+  fancy-canvas@2.1.0: {}
+
   fast-base64-decode@1.0.0: {}
 
   fast-check@3.1.1:
@@ -42375,6 +42440,10 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  lightweight-charts@4.2.3:
+    dependencies:
+      fancy-canvas: 2.1.0
+
   lilconfig@2.1.0: {}
 
   lilconfig@3.1.2: {}

+ 1 - 0
pnpm-workspace.yaml

@@ -65,6 +65,7 @@ catalog:
   eslint: 9.13.0
   framer-motion: 11.11.10
   jest: 29.7.0
+  lightweight-charts: ^4.2.3
   modern-normalize: 3.0.1
   motion: 11.14.4
   next-themes: 0.3.0