Skip to content
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
6 changes: 4 additions & 2 deletions apps/insights/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"bs58": "catalog:",
"clsx": "catalog:",
"cryptocurrency-icons": "catalog:",
"date-fns": "catalog:",
"csv-stringify": "catalog:",
"dnum": "catalog:",
"ioredis": "^5.7.0",
"lightweight-charts": "catalog:",
Expand All @@ -46,8 +48,8 @@
"superjson": "catalog:",
"swr": "catalog:",
"zod": "catalog:",
"zod-validation-error": "catalog:",
"zod-search-params": "catalog:"
"zod-search-params": "catalog:",
"zod-validation-error": "catalog:"
},
"devDependencies": {
"@cprussin/eslint-config": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion apps/insights/src/app/api/pyth/get-feeds/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,5 @@ export const GET = async (request: NextRequest) => {

const queryParamsSchema = z.object({
cluster: z.enum(CLUSTER_NAMES).transform((value) => toCluster(value)),
excludePriceComponents: z.boolean(),
excludePriceComponents: z.boolean().optional().default(false),
});
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);
}
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;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const WEB_API_BASE_URL = "https://web-api.pyth.network";
2 changes: 2 additions & 0 deletions apps/insights/src/components/ConformanceReport/types.ts
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];
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],
);
};
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],
);
};
Loading
Loading