Skip to content

Commit 554e0f4

Browse files
authored
Merge pull request #2993 from pyth-network/feat/conformance-reports-temp
feat(insights): conformance reports
2 parents 56465a9 + d4748be commit 554e0f4

File tree

19 files changed

+550
-92
lines changed

19 files changed

+550
-92
lines changed

apps/insights/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
"bs58": "catalog:",
3333
"clsx": "catalog:",
3434
"cryptocurrency-icons": "catalog:",
35+
"date-fns": "catalog:",
36+
"csv-stringify": "catalog:",
3537
"dnum": "catalog:",
3638
"ioredis": "^5.7.0",
3739
"lightweight-charts": "catalog:",
@@ -46,8 +48,8 @@
4648
"superjson": "catalog:",
4749
"swr": "catalog:",
4850
"zod": "catalog:",
49-
"zod-validation-error": "catalog:",
50-
"zod-search-params": "catalog:"
51+
"zod-search-params": "catalog:",
52+
"zod-validation-error": "catalog:"
5153
},
5254
"devDependencies": {
5355
"@cprussin/eslint-config": "catalog:",

apps/insights/src/app/api/pyth/get-feeds/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,5 @@ export const GET = async (request: NextRequest) => {
3737

3838
const queryParamsSchema = z.object({
3939
cluster: z.enum(CLUSTER_NAMES).transform((value) => toCluster(value)),
40-
excludePriceComponents: z.boolean(),
40+
excludePriceComponents: z.boolean().optional().default(false),
4141
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
@use "@pythnetwork/component-library/theme";
2+
3+
.conformanceReport {
4+
display: flex;
5+
gap: theme.spacing(2);
6+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"use client";
2+
3+
import { Download } from "@phosphor-icons/react/dist/ssr/Download";
4+
import { Button } from "@pythnetwork/component-library/Button";
5+
import { Select } from "@pythnetwork/component-library/Select";
6+
import { useAlert } from "@pythnetwork/component-library/useAlert";
7+
import { useLogger } from "@pythnetwork/component-library/useLogger";
8+
import { useCallback, useState } from "react";
9+
10+
import styles from "./conformance-report.module.scss";
11+
import type { Interval } from "./types";
12+
import { INTERVALS } from "./types";
13+
14+
type ConformanceReportProps = {
15+
onClick: (timeframe: Interval) => Promise<void>;
16+
};
17+
18+
const ConformanceReport = (props: ConformanceReportProps) => {
19+
const [timeframe, setTimeframe] = useState<Interval>(INTERVALS[0]);
20+
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
21+
const { open } = useAlert();
22+
const logger = useLogger();
23+
24+
/**
25+
* Download the conformance report for the given symbol or publisher
26+
*/
27+
const downloadReport = useCallback(async () => {
28+
await props.onClick(timeframe);
29+
}, [props, timeframe]);
30+
31+
const handleReport = () => {
32+
setIsGeneratingReport(true);
33+
downloadReport()
34+
.catch((error: unknown) => {
35+
open({
36+
title: "Error",
37+
contents: "Error generating conformance report",
38+
});
39+
logger.error(error);
40+
})
41+
.finally(() => {
42+
setIsGeneratingReport(false);
43+
});
44+
};
45+
46+
return (
47+
<div className={styles.conformanceReport}>
48+
<Select
49+
options={INTERVALS.map((interval) => ({ id: interval }))}
50+
placement="bottom end"
51+
selectedKey={timeframe}
52+
onSelectionChange={setTimeframe}
53+
size="sm"
54+
label="Timeframe"
55+
variant="outline"
56+
hideLabel
57+
/>
58+
<Button
59+
variant="outline"
60+
size="sm"
61+
onClick={handleReport}
62+
afterIcon={<Download key="download" />}
63+
isPending={isGeneratingReport}
64+
>
65+
Report
66+
</Button>
67+
</div>
68+
);
69+
};
70+
71+
export default ConformanceReport;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const WEB_API_BASE_URL = "https://web-api.pyth.network";
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const INTERVALS = ["24H", "48H", "72H", "1W", "1M"] as const;
2+
export type Interval = (typeof INTERVALS)[number];
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useCallback } from "react";
2+
3+
import { WEB_API_BASE_URL } from "./constants";
4+
import type { Interval } from "./types";
5+
import { useDownloadBlob } from "../../hooks/use-download-blob";
6+
import { CLUSTER_NAMES } from "../../services/pyth";
7+
8+
const PYTHTEST_CONFORMANCE_REFERENCE_PUBLISHER =
9+
"HUZu4xMSHbxTWbkXR6jkGdjvDPJLjrpSNXSoUFBRgjWs";
10+
11+
export const useDownloadReportForFeed = () => {
12+
const download = useDownloadBlob();
13+
14+
return useCallback(
15+
async ({
16+
symbol,
17+
publisher,
18+
timeframe,
19+
cluster,
20+
}: {
21+
symbol: string;
22+
publisher: string;
23+
timeframe: Interval;
24+
cluster: (typeof CLUSTER_NAMES)[number];
25+
}) => {
26+
const url = new URL("/metrics/conformance", WEB_API_BASE_URL);
27+
url.searchParams.set("symbol", symbol);
28+
url.searchParams.set("range", timeframe);
29+
url.searchParams.set("cluster", cluster);
30+
url.searchParams.set("publisher", publisher);
31+
32+
if (cluster === "pythtest-conformance") {
33+
url.searchParams.set(
34+
"pythnet_aggregate_publisher",
35+
PYTHTEST_CONFORMANCE_REFERENCE_PUBLISHER,
36+
);
37+
}
38+
39+
const response = await fetch(url, {
40+
headers: new Headers({
41+
Accept: "application/octet-stream",
42+
}),
43+
});
44+
const blob = await response.blob();
45+
download(
46+
blob,
47+
`${publisher}-${symbol
48+
.split("/")
49+
.join("")}-${timeframe}-${cluster}-conformance-report.tsv`,
50+
);
51+
},
52+
[download],
53+
);
54+
};
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { stringify as stringifyCsv } from "csv-stringify/sync";
2+
import {
3+
addDays,
4+
differenceInDays,
5+
format,
6+
isBefore,
7+
startOfMonth,
8+
startOfWeek,
9+
subMonths,
10+
} from "date-fns";
11+
import { useCallback } from "react";
12+
import { parse } from "superjson";
13+
import { z } from "zod";
14+
15+
import { WEB_API_BASE_URL } from "./constants";
16+
import type { Interval } from "./types";
17+
import { useDownloadBlob } from "../../hooks/use-download-blob";
18+
import { priceFeedsSchema } from "../../schemas/pyth/price-feeds-schema";
19+
import { CLUSTER_NAMES } from "../../services/pyth";
20+
21+
// If interval is 'daily', set interval_days=1
22+
// If interval is 'weekly', get the previous Sunday and set interval_days=7
23+
// If interval is 'monthly', get the 15th of the current month and set interval_day to the
24+
// difference between the 15th of the current month and the 15th of the previous month which is 28-31 days.
25+
const getRankingDateAndIntervalDays = (date: Date, interval: Interval) => {
26+
switch (interval) {
27+
case "24H": {
28+
return {
29+
date,
30+
intervalDays: 1,
31+
};
32+
}
33+
case "48H": {
34+
return {
35+
date,
36+
intervalDays: 2,
37+
};
38+
}
39+
case "72H": {
40+
return {
41+
date,
42+
intervalDays: 3,
43+
};
44+
}
45+
case "1W": {
46+
return {
47+
date: startOfWeek(date),
48+
intervalDays: 7,
49+
};
50+
}
51+
case "1M": {
52+
const monthStart = startOfMonth(date);
53+
let midMonth = addDays(monthStart, 14);
54+
if (isBefore(date, midMonth)) {
55+
midMonth = subMonths(midMonth, 1);
56+
}
57+
const midMonthBefore = subMonths(midMonth, 1);
58+
return {
59+
date: midMonth,
60+
intervalDays: differenceInDays(midMonth, midMonthBefore),
61+
};
62+
}
63+
}
64+
};
65+
66+
const getFeeds = async (cluster: (typeof CLUSTER_NAMES)[number]) => {
67+
const url = new URL(`/api/pyth/get-feeds`, globalThis.window.origin);
68+
url.searchParams.set("cluster", cluster);
69+
const data = await fetch(url);
70+
const rawData = await data.text();
71+
const parsedData = parse(rawData);
72+
return priceFeedsSchema.element.array().parse(parsedData);
73+
};
74+
75+
const publisherQualityScoreSchema = z.object({
76+
symbol: z.string(),
77+
uptime_score: z.string(),
78+
deviation_penalty: z.string(),
79+
deviation_score: z.string(),
80+
stalled_penalty: z.string(),
81+
stalled_score: z.string(),
82+
final_score: z.string(),
83+
});
84+
85+
const publisherQuantityScoreSchema = z.object({
86+
numSymbols: z.number(),
87+
rank: z.number(),
88+
symbols: z.array(z.string()),
89+
timestamp: z.string(),
90+
});
91+
92+
const fetchRankingData = async (
93+
cluster: (typeof CLUSTER_NAMES)[number],
94+
publisher: string,
95+
interval: Interval,
96+
) => {
97+
const { date, intervalDays } = getRankingDateAndIntervalDays(
98+
new Date(),
99+
interval,
100+
);
101+
const quantityRankUrl = new URL(`/publisher_ranking`, WEB_API_BASE_URL);
102+
quantityRankUrl.searchParams.set("cluster", cluster);
103+
quantityRankUrl.searchParams.set("publisher", publisher);
104+
const qualityRankUrl = new URL(
105+
`/publisher_quality_ranking_score`,
106+
WEB_API_BASE_URL,
107+
);
108+
qualityRankUrl.searchParams.set("cluster", cluster);
109+
qualityRankUrl.searchParams.set("publisher", publisher);
110+
qualityRankUrl.searchParams.set("date", format(date, "yyyy-MM-dd"));
111+
qualityRankUrl.searchParams.set("interval_days", intervalDays.toString());
112+
113+
const [quantityRankRes, qualityRankRes] = await Promise.all([
114+
fetch(quantityRankUrl),
115+
fetch(qualityRankUrl),
116+
]);
117+
118+
return {
119+
quantityRankData: publisherQuantityScoreSchema
120+
.array()
121+
.parse(await quantityRankRes.json()),
122+
qualityRankData: publisherQualityScoreSchema
123+
.array()
124+
.parse(await qualityRankRes.json()),
125+
};
126+
};
127+
const csvHeaders = [
128+
"priceFeed",
129+
"assetType",
130+
"description",
131+
"status",
132+
"permissioned",
133+
"uptime_score",
134+
"deviation_penalty",
135+
"deviation_score",
136+
"stalled_penalty",
137+
"stalled_score",
138+
"final_score",
139+
];
140+
141+
const symbolsSort = (a: string, b: string) => {
142+
const aSplit = a.split(".");
143+
const bSplit = b.split(".");
144+
const aLast = aSplit.at(-1);
145+
const bLast = bSplit.at(-1);
146+
return aLast?.localeCompare(bLast ?? "") ?? 0;
147+
};
148+
149+
export const useDownloadReportForPublisher = () => {
150+
const download = useDownloadBlob();
151+
152+
return useCallback(
153+
async ({
154+
publisher,
155+
cluster,
156+
interval,
157+
}: {
158+
publisher: string;
159+
cluster: (typeof CLUSTER_NAMES)[number];
160+
interval: Interval;
161+
}) => {
162+
const [rankingData, allFeeds] = await Promise.all([
163+
fetchRankingData(cluster, publisher, interval),
164+
getFeeds(cluster),
165+
]);
166+
167+
const isPermissioned = (feed: string) =>
168+
allFeeds
169+
.find((f) => f.symbol === feed)
170+
?.price.priceComponents.some((c) => c.publisher === publisher);
171+
172+
const getPriceFeedData = (feed: string) => {
173+
const rankData = rankingData.qualityRankData.find(
174+
(obj) => obj.symbol === feed,
175+
);
176+
const feedMetadata = allFeeds.find((f) => f.symbol === feed);
177+
return {
178+
priceFeed: feedMetadata?.product.display_symbol ?? "",
179+
assetType: feedMetadata?.product.asset_type ?? "",
180+
description: feedMetadata?.product.description ?? "",
181+
...rankData,
182+
};
183+
};
184+
185+
const activePriceFeeds =
186+
rankingData.quantityRankData[0]?.symbols.sort(symbolsSort) ?? [];
187+
188+
const allSymbols = allFeeds
189+
.map((feed) => feed.symbol)
190+
.filter((symbol: string) => symbol && !symbol.includes("NULL"));
191+
192+
// filter out inactive price feeds
193+
const inactivePriceFeeds = allSymbols
194+
.filter((symbol) => {
195+
const meta = allFeeds.find((f) => f.symbol === symbol);
196+
return (
197+
meta !== undefined &&
198+
!activePriceFeeds.includes(symbol) &&
199+
meta.price.numComponentPrices > 0
200+
);
201+
})
202+
.sort(symbolsSort);
203+
204+
const data = [
205+
...activePriceFeeds.map((feed) => ({
206+
...getPriceFeedData(feed),
207+
status: "active",
208+
permissioned: "permissioned",
209+
})),
210+
...inactivePriceFeeds.map((feed) => ({
211+
...getPriceFeedData(feed),
212+
status: "inactive",
213+
permissioned: isPermissioned(feed)
214+
? "permissioned"
215+
: "unpermissioned",
216+
})),
217+
];
218+
219+
const csv = stringifyCsv(data, { header: true, columns: csvHeaders });
220+
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
221+
download(blob, `${publisher}-${cluster}-price-feeds.csv`);
222+
},
223+
[download],
224+
);
225+
};

0 commit comments

Comments
 (0)