-
Notifications
You must be signed in to change notification settings - Fork 308
feat(insights): conformance reports #2993
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
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
991c32b
feat: publisher conformance report
alexcambose 0236b35
feat: feed report
alexcambose 742cee6
feat: publisher conformance report
alexcambose 4b21b82
fix: types
alexcambose 38f7eaf
fix: minor fixes
alexcambose f9b2665
fix: PR fixes
alexcambose d4748be
Merge branch 'main' into feat/conformance-reports-temp
alexcambose File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
6 changes: 6 additions & 0 deletions
6
apps/insights/src/components/ConformanceReport/conformance-report.module.scss
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| @use "@pythnetwork/component-library/theme"; | ||
|
|
||
| .conformanceReport { | ||
| display: flex; | ||
| gap: theme.spacing(2); | ||
| } |
71 changes: 71 additions & 0 deletions
71
apps/insights/src/components/ConformanceReport/conformance-report.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| "use client"; | ||
|
|
||
| import { Download } from "@phosphor-icons/react/dist/ssr/Download"; | ||
| import { Button } from "@pythnetwork/component-library/Button"; | ||
| import { Select } from "@pythnetwork/component-library/Select"; | ||
| import { useAlert } from "@pythnetwork/component-library/useAlert"; | ||
| import { useLogger } from "@pythnetwork/component-library/useLogger"; | ||
| import { useCallback, useState } from "react"; | ||
|
|
||
| import styles from "./conformance-report.module.scss"; | ||
| import type { Interval } from "./types"; | ||
| import { INTERVALS } from "./types"; | ||
|
|
||
| type ConformanceReportProps = { | ||
| onClick: (timeframe: Interval) => Promise<void>; | ||
| }; | ||
|
|
||
| const ConformanceReport = (props: ConformanceReportProps) => { | ||
| const [timeframe, setTimeframe] = useState<Interval>(INTERVALS[0]); | ||
| const [isGeneratingReport, setIsGeneratingReport] = useState(false); | ||
| const { open } = useAlert(); | ||
| const logger = useLogger(); | ||
|
|
||
| /** | ||
| * Download the conformance report for the given symbol or publisher | ||
| */ | ||
| const downloadReport = useCallback(async () => { | ||
| await props.onClick(timeframe); | ||
| }, [props, timeframe]); | ||
|
|
||
| const handleReport = () => { | ||
| setIsGeneratingReport(true); | ||
| downloadReport() | ||
| .catch((error: unknown) => { | ||
| open({ | ||
| title: "Error", | ||
| contents: "Error generating conformance report", | ||
| }); | ||
| logger.error(error); | ||
| }) | ||
| .finally(() => { | ||
| setIsGeneratingReport(false); | ||
| }); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className={styles.conformanceReport}> | ||
| <Select | ||
| options={INTERVALS.map((interval) => ({ id: interval }))} | ||
| placement="bottom end" | ||
| selectedKey={timeframe} | ||
| onSelectionChange={setTimeframe} | ||
| size="sm" | ||
| label="Timeframe" | ||
| variant="outline" | ||
| hideLabel | ||
| /> | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={handleReport} | ||
| afterIcon={<Download key="download" />} | ||
| isPending={isGeneratingReport} | ||
| > | ||
| Report | ||
| </Button> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default ConformanceReport; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const WEB_API_BASE_URL = "https://web-api.pyth.network"; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export const INTERVALS = ["24H", "48H", "72H", "1W", "1M"] as const; | ||
| export type Interval = (typeof INTERVALS)[number]; |
54 changes: 54 additions & 0 deletions
54
apps/insights/src/components/ConformanceReport/use-download-report-for-feed.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { useCallback } from "react"; | ||
|
|
||
| import { WEB_API_BASE_URL } from "./constants"; | ||
| import type { Interval } from "./types"; | ||
| import { useDownloadBlob } from "../../hooks/use-download-blob"; | ||
| import { CLUSTER_NAMES } from "../../services/pyth"; | ||
|
|
||
| const PYTHTEST_CONFORMANCE_REFERENCE_PUBLISHER = | ||
| "HUZu4xMSHbxTWbkXR6jkGdjvDPJLjrpSNXSoUFBRgjWs"; | ||
|
|
||
| export const useDownloadReportForFeed = () => { | ||
| const download = useDownloadBlob(); | ||
|
|
||
| return useCallback( | ||
| async ({ | ||
| symbol, | ||
| publisher, | ||
| timeframe, | ||
| cluster, | ||
| }: { | ||
| symbol: string; | ||
| publisher: string; | ||
| timeframe: Interval; | ||
| cluster: (typeof CLUSTER_NAMES)[number]; | ||
| }) => { | ||
| const url = new URL("/metrics/conformance", WEB_API_BASE_URL); | ||
| url.searchParams.set("symbol", symbol); | ||
| url.searchParams.set("range", timeframe); | ||
| url.searchParams.set("cluster", cluster); | ||
| url.searchParams.set("publisher", publisher); | ||
|
|
||
| if (cluster === "pythtest-conformance") { | ||
| url.searchParams.set( | ||
| "pythnet_aggregate_publisher", | ||
| PYTHTEST_CONFORMANCE_REFERENCE_PUBLISHER, | ||
| ); | ||
| } | ||
|
|
||
| const response = await fetch(url, { | ||
| headers: new Headers({ | ||
| Accept: "application/octet-stream", | ||
| }), | ||
| }); | ||
| const blob = await response.blob(); | ||
| download( | ||
| blob, | ||
| `${publisher}-${symbol | ||
| .split("/") | ||
| .join("")}-${timeframe}-${cluster}-conformance-report.tsv`, | ||
| ); | ||
| }, | ||
| [download], | ||
| ); | ||
| }; |
225 changes: 225 additions & 0 deletions
225
apps/insights/src/components/ConformanceReport/use-download-report-for-publisher.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| import { stringify as stringifyCsv } from "csv-stringify/sync"; | ||
| import { | ||
| addDays, | ||
| differenceInDays, | ||
| format, | ||
| isBefore, | ||
| startOfMonth, | ||
| startOfWeek, | ||
| subMonths, | ||
| } from "date-fns"; | ||
| import { useCallback } from "react"; | ||
| import { parse } from "superjson"; | ||
| import { z } from "zod"; | ||
|
|
||
| import { WEB_API_BASE_URL } from "./constants"; | ||
| import type { Interval } from "./types"; | ||
| import { useDownloadBlob } from "../../hooks/use-download-blob"; | ||
| import { priceFeedsSchema } from "../../schemas/pyth/price-feeds-schema"; | ||
| import { CLUSTER_NAMES } from "../../services/pyth"; | ||
|
|
||
| // If interval is 'daily', set interval_days=1 | ||
| // If interval is 'weekly', get the previous Sunday and set interval_days=7 | ||
| // If interval is 'monthly', get the 15th of the current month and set interval_day to the | ||
| // difference between the 15th of the current month and the 15th of the previous month which is 28-31 days. | ||
| const getRankingDateAndIntervalDays = (date: Date, interval: Interval) => { | ||
| switch (interval) { | ||
| case "24H": { | ||
| return { | ||
| date, | ||
| intervalDays: 1, | ||
| }; | ||
| } | ||
| case "48H": { | ||
| return { | ||
| date, | ||
| intervalDays: 2, | ||
| }; | ||
| } | ||
| case "72H": { | ||
| return { | ||
| date, | ||
| intervalDays: 3, | ||
| }; | ||
| } | ||
| case "1W": { | ||
| return { | ||
| date: startOfWeek(date), | ||
| intervalDays: 7, | ||
| }; | ||
| } | ||
| case "1M": { | ||
| const monthStart = startOfMonth(date); | ||
| let midMonth = addDays(monthStart, 14); | ||
| if (isBefore(date, midMonth)) { | ||
| midMonth = subMonths(midMonth, 1); | ||
| } | ||
| const midMonthBefore = subMonths(midMonth, 1); | ||
| return { | ||
| date: midMonth, | ||
| intervalDays: differenceInDays(midMonth, midMonthBefore), | ||
| }; | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| const getFeeds = async (cluster: (typeof CLUSTER_NAMES)[number]) => { | ||
| const url = new URL(`/api/pyth/get-feeds`, globalThis.window.origin); | ||
| url.searchParams.set("cluster", cluster); | ||
| const data = await fetch(url); | ||
| const rawData = await data.text(); | ||
| const parsedData = parse(rawData); | ||
| return priceFeedsSchema.element.array().parse(parsedData); | ||
| }; | ||
|
|
||
| const publisherQualityScoreSchema = z.object({ | ||
| symbol: z.string(), | ||
| uptime_score: z.string(), | ||
| deviation_penalty: z.string(), | ||
| deviation_score: z.string(), | ||
| stalled_penalty: z.string(), | ||
| stalled_score: z.string(), | ||
| final_score: z.string(), | ||
| }); | ||
|
|
||
| const publisherQuantityScoreSchema = z.object({ | ||
| numSymbols: z.number(), | ||
| rank: z.number(), | ||
| symbols: z.array(z.string()), | ||
| timestamp: z.string(), | ||
| }); | ||
|
|
||
| const fetchRankingData = async ( | ||
| cluster: (typeof CLUSTER_NAMES)[number], | ||
| publisher: string, | ||
| interval: Interval, | ||
| ) => { | ||
| const { date, intervalDays } = getRankingDateAndIntervalDays( | ||
| new Date(), | ||
| interval, | ||
| ); | ||
| const quantityRankUrl = new URL(`/publisher_ranking`, WEB_API_BASE_URL); | ||
| quantityRankUrl.searchParams.set("cluster", cluster); | ||
| quantityRankUrl.searchParams.set("publisher", publisher); | ||
| const qualityRankUrl = new URL( | ||
| `/publisher_quality_ranking_score`, | ||
| WEB_API_BASE_URL, | ||
| ); | ||
| qualityRankUrl.searchParams.set("cluster", cluster); | ||
| qualityRankUrl.searchParams.set("publisher", publisher); | ||
| qualityRankUrl.searchParams.set("date", format(date, "yyyy-MM-dd")); | ||
| qualityRankUrl.searchParams.set("interval_days", intervalDays.toString()); | ||
|
|
||
| const [quantityRankRes, qualityRankRes] = await Promise.all([ | ||
| fetch(quantityRankUrl), | ||
| fetch(qualityRankUrl), | ||
| ]); | ||
|
|
||
| return { | ||
| quantityRankData: publisherQuantityScoreSchema | ||
| .array() | ||
| .parse(await quantityRankRes.json()), | ||
| qualityRankData: publisherQualityScoreSchema | ||
| .array() | ||
| .parse(await qualityRankRes.json()), | ||
| }; | ||
| }; | ||
| const csvHeaders = [ | ||
| "priceFeed", | ||
| "assetType", | ||
| "description", | ||
| "status", | ||
| "permissioned", | ||
| "uptime_score", | ||
| "deviation_penalty", | ||
| "deviation_score", | ||
| "stalled_penalty", | ||
| "stalled_score", | ||
| "final_score", | ||
| ]; | ||
|
|
||
| const symbolsSort = (a: string, b: string) => { | ||
| const aSplit = a.split("."); | ||
| const bSplit = b.split("."); | ||
| const aLast = aSplit.at(-1); | ||
| const bLast = bSplit.at(-1); | ||
| return aLast?.localeCompare(bLast ?? "") ?? 0; | ||
| }; | ||
|
|
||
| export const useDownloadReportForPublisher = () => { | ||
| const download = useDownloadBlob(); | ||
|
|
||
| return useCallback( | ||
| async ({ | ||
| publisher, | ||
| cluster, | ||
| interval, | ||
| }: { | ||
| publisher: string; | ||
| cluster: (typeof CLUSTER_NAMES)[number]; | ||
| interval: Interval; | ||
| }) => { | ||
| const [rankingData, allFeeds] = await Promise.all([ | ||
| fetchRankingData(cluster, publisher, interval), | ||
| getFeeds(cluster), | ||
| ]); | ||
|
|
||
| const isPermissioned = (feed: string) => | ||
| allFeeds | ||
| .find((f) => f.symbol === feed) | ||
| ?.price.priceComponents.some((c) => c.publisher === publisher); | ||
|
|
||
| const getPriceFeedData = (feed: string) => { | ||
| const rankData = rankingData.qualityRankData.find( | ||
| (obj) => obj.symbol === feed, | ||
| ); | ||
| const feedMetadata = allFeeds.find((f) => f.symbol === feed); | ||
| return { | ||
| priceFeed: feedMetadata?.product.display_symbol ?? "", | ||
| assetType: feedMetadata?.product.asset_type ?? "", | ||
| description: feedMetadata?.product.description ?? "", | ||
| ...rankData, | ||
| }; | ||
| }; | ||
|
|
||
| const activePriceFeeds = | ||
| rankingData.quantityRankData[0]?.symbols.sort(symbolsSort) ?? []; | ||
|
|
||
| const allSymbols = allFeeds | ||
| .map((feed) => feed.symbol) | ||
| .filter((symbol: string) => symbol && !symbol.includes("NULL")); | ||
|
|
||
| // filter out inactive price feeds | ||
| const inactivePriceFeeds = allSymbols | ||
| .filter((symbol) => { | ||
| const meta = allFeeds.find((f) => f.symbol === symbol); | ||
| return ( | ||
| meta !== undefined && | ||
| !activePriceFeeds.includes(symbol) && | ||
| meta.price.numComponentPrices > 0 | ||
| ); | ||
| }) | ||
| .sort(symbolsSort); | ||
|
|
||
| const data = [ | ||
| ...activePriceFeeds.map((feed) => ({ | ||
| ...getPriceFeedData(feed), | ||
| status: "active", | ||
| permissioned: "permissioned", | ||
| })), | ||
| ...inactivePriceFeeds.map((feed) => ({ | ||
| ...getPriceFeedData(feed), | ||
| status: "inactive", | ||
| permissioned: isPermissioned(feed) | ||
| ? "permissioned" | ||
| : "unpermissioned", | ||
| })), | ||
| ]; | ||
|
|
||
| const csv = stringifyCsv(data, { header: true, columns: csvHeaders }); | ||
| const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); | ||
| download(blob, `${publisher}-${cluster}-price-feeds.csv`); | ||
| }, | ||
| [download], | ||
| ); | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.