diff --git a/apps/insights/src/app/component-score-history/route.ts b/apps/insights/src/app/component-score-history/route.ts index 90b8c5224b..ce59e8d65b 100644 --- a/apps/insights/src/app/component-score-history/route.ts +++ b/apps/insights/src/app/component-score-history/route.ts @@ -15,8 +15,14 @@ export const GET = async (req: NextRequest) => { ), ); if (parsed.success) { - const { cluster, publisherKey, symbol } = parsed.data; - const data = await getFeedScoreHistory(cluster, publisherKey, symbol); + const { cluster, publisherKey, symbol, from, to } = parsed.data; + const data = await getFeedScoreHistory( + cluster, + publisherKey, + symbol, + from, + to, + ); return Response.json(data); } else { return new Response(fromError(parsed.error).toString(), { @@ -29,4 +35,6 @@ const queryParamsSchema = z.object({ cluster: z.enum(CLUSTER_NAMES).transform((value) => toCluster(value)), publisherKey: z.string(), symbol: z.string().transform((value) => decodeURIComponent(value)), + from: z.string(), + to: z.string(), }); diff --git a/apps/insights/src/components/LivePrices/index.tsx b/apps/insights/src/components/LivePrices/index.tsx index 89643e52fd..afca9f2d41 100644 --- a/apps/insights/src/components/LivePrices/index.tsx +++ b/apps/insights/src/components/LivePrices/index.tsx @@ -146,7 +146,11 @@ export const LiveValue = ({ }: LiveValueProps) => { const { current } = useLivePriceData(feedKey); - return current?.[field]?.toString() ?? defaultValue; + return current !== undefined || defaultValue !== undefined ? ( + (current?.[field]?.toString() ?? defaultValue) + ) : ( + + ); }; type LiveComponentValueProps = { @@ -164,7 +168,11 @@ export const LiveComponentValue = ({ }: LiveComponentValueProps) => { const { current } = useLivePriceComponent(feedKey, publisherKey); - return current?.latest[field].toString() ?? defaultValue; + return current !== undefined || defaultValue !== undefined ? ( + (current?.latest[field].toString() ?? defaultValue) + ) : ( + + ); }; const isToday = (date: Date) => { diff --git a/apps/insights/src/components/PriceComponentDrawer/index.module.scss b/apps/insights/src/components/PriceComponentDrawer/index.module.scss index f09ecc1522..56c0f6d6d1 100644 --- a/apps/insights/src/components/PriceComponentDrawer/index.module.scss +++ b/apps/insights/src/components/PriceComponentDrawer/index.module.scss @@ -17,4 +17,164 @@ margin: theme.spacing(40) auto; font-size: theme.spacing(16); } + + .rankingBreakdown { + .scoreHistoryChart { + grid-column: span 2 / span 2; + border-radius: theme.border-radius("2xl"); + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); + background: theme.color("background", "primary"); + margin-bottom: theme.spacing(2); + + .top { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: flex-start; + margin: theme.spacing(4); + + .left { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(1); + + .header { + color: theme.color("heading"); + + @include theme.text("sm", "medium"); + } + + .subheader { + color: theme.color("muted"); + + @include theme.text("xs", "normal"); + } + } + } + + .chart { + border-bottom-left-radius: theme.border-radius("2xl"); + border-bottom-right-radius: theme.border-radius("2xl"); + overflow: hidden; + + .score, + .uptimeScore, + .deviationScore, + .stalledScore { + transition: opacity 100ms linear; + opacity: 0.2; + } + + .score { + color: theme.color("states", "data", "normal"); + } + + .uptimeScore { + color: theme.color("states", "info", "normal"); + } + + .deviationScore { + color: theme.color("states", "lime", "normal"); + } + + .stalledScore { + color: theme.color("states", "warning", "normal"); + } + } + + &:not([data-focused-score], [data-hovered-score]) { + .score, + .uptimeScore, + .deviationScore, + .stalledScore { + opacity: 1; + } + } + + &[data-hovered-score="uptime"], + &[data-focused-score="uptime"] { + .uptimeScore { + opacity: 1; + } + } + + &[data-hovered-score="deviation"], + &[data-focused-score="deviation"] { + .deviationScore { + opacity: 1; + } + } + + &[data-hovered-score="stalled"], + &[data-focused-score="stalled"] { + .stalledScore { + opacity: 1; + } + } + + &[data-hovered-score="final"], + &[data-focused-score="final"] { + .score { + opacity: 1; + } + } + } + + .date { + @include theme.text("sm", "normal"); + + margin: theme.spacing(2) theme.spacing(4); + } + + .scoreCell { + vertical-align: top; + } + + .metric { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(2); + overflow: hidden; + + .metricName { + display: flex; + flex-flow: row nowwrap; + align-items: center; + gap: theme.spacing(2); + + .legend { + width: theme.spacing(4); + height: theme.spacing(4); + fill: none; + } + } + + .metricDescription { + color: theme.color("muted"); + + @include theme.text("sm", "normal"); + + white-space: normal; + line-height: 1.2; + } + + &[data-component="uptime"] .legend { + stroke: theme.color("states", "info", "normal"); + } + + &[data-component="deviation"] .legend { + stroke: theme.color("states", "lime", "normal"); + } + + &[data-component="stalled"] .legend { + stroke: theme.color("states", "warning", "normal"); + } + + &[data-component="final"] .legend { + stroke: theme.color("states", "data", "normal"); + } + } + } } diff --git a/apps/insights/src/components/PriceComponentDrawer/index.tsx b/apps/insights/src/components/PriceComponentDrawer/index.tsx index a1d02a6b59..dfb9de4df5 100644 --- a/apps/insights/src/components/PriceComponentDrawer/index.tsx +++ b/apps/insights/src/components/PriceComponentDrawer/index.tsx @@ -1,10 +1,27 @@ import { Button } from "@pythnetwork/component-library/Button"; +import { Card } from "@pythnetwork/component-library/Card"; import { Drawer } from "@pythnetwork/component-library/Drawer"; +import { Select } from "@pythnetwork/component-library/Select"; import { Spinner } from "@pythnetwork/component-library/Spinner"; import { StatCard } from "@pythnetwork/component-library/StatCard"; +import { Table } from "@pythnetwork/component-library/Table"; +import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; -import { type ReactNode, useState, useRef, useCallback } from "react"; -import { RouterProvider } from "react-aria"; +import { + type ReactNode, + Suspense, + useState, + useRef, + useCallback, + useMemo, +} from "react"; +import { + RouterProvider, + useDateFormatter, + useNumberFormatter, +} from "react-aria"; +import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts"; +import type { CategoricalChartState } from "recharts/types/chart/types"; import { z } from "zod"; import styles from "./index.module.scss"; @@ -13,9 +30,15 @@ import { Cluster, ClusterToName } from "../../services/pyth"; import type { Status } from "../../status"; import { LiveConfidence, LivePrice, LiveComponentValue } from "../LivePrices"; import { Score } from "../Score"; -import { ScoreHistory as ScoreHistoryComponent } from "../ScoreHistory"; import { Status as StatusComponent } from "../Status"; +const LineChart = dynamic( + () => import("recharts").then((recharts) => recharts.LineChart), + { + ssr: false, + }, +); + type Props = { onClose: () => void; title: ReactNode; @@ -28,6 +51,7 @@ type Props = { status: Status; navigateButtonText: string; navigateHref: string; + firstEvaluation: Date; }; export const PriceComponentDrawer = ({ @@ -42,6 +66,7 @@ export const PriceComponentDrawer = ({ headingExtra, navigateButtonText, navigateHref, + firstEvaluation, }: Props) => { const goToPriceFeedPageOnClose = useRef(false); const [isFeedDrawerOpen, setIsFeedDrawerOpen] = useState(true); @@ -65,8 +90,10 @@ export const PriceComponentDrawer = ({ goToPriceFeedPageOnClose.current = true; setIsFeedDrawerOpen(false); }, [setIsFeedDrawerOpen]); + const { selectedPeriod, setSelectedPeriod, evaluationPeriods } = + useEvaluationPeriods(firstEvaluation); const scoreHistoryState = useData( - [Cluster.Pythnet, publisherKey, symbol], + [Cluster.Pythnet, publisherKey, symbol, selectedPeriod], getScoreHistory, ); @@ -136,16 +163,97 @@ export const PriceComponentDrawer = ({ stat={rank ?? <>} /> - + { + const evaluationPeriod = evaluationPeriods.find( + (period) => period.label === label, + ); + if (evaluationPeriod) { + setSelectedPeriod(evaluationPeriod); + } + }} + options={evaluationPeriods.map(({ label }) => label)} + placement="bottom end" + /> + } + > + + ); }; -const ScoreHistory = ({ - state, -}: { +const useEvaluationPeriods = (firstEvaluation: Date) => { + const dateFormatter = useDateFormatter({ + dateStyle: "medium", + timeZone: "UTC", + }); + + const evaluationPeriods = useMemo< + [EvaluationPeriod, ...EvaluationPeriod[]] + >(() => { + const evaluations: EvaluationPeriod[] = []; + const today = new Date(); + const cursor = new Date(firstEvaluation); + cursor.setHours(0); + cursor.setMinutes(0); + cursor.setSeconds(0); + cursor.setMilliseconds(0); + // Evaluations are between the 16th of one month and the 15th of the next + // month, so move the cursor to the first evaluation boundary before the + // first evaluation. + if (cursor.getDate() < 16) { + cursor.setMonth(cursor.getMonth() - 1); + } + cursor.setDate(16); + while (cursor < today) { + const start = new Date(cursor); + cursor.setMonth(cursor.getMonth() + 1); + const end = new Date(cursor); + end.setDate(15); + evaluations.unshift({ + start, + end, + label: `${dateFormatter.format(start)} to ${end < today ? dateFormatter.format(end) : "Now"}`, + }); + } + + // This ensures that typescript understands that this array is nonempty + const [head, ...tail] = evaluations; + if (!head) { + throw new Error("Failed invariant: No first evaluation!"); + } + return [head, ...tail]; + }, [firstEvaluation, dateFormatter]); + + const [selectedPeriod, setSelectedPeriod] = useState( + evaluationPeriods[0], + ); + + return { selectedPeriod, setSelectedPeriod, evaluationPeriods }; +}; + +type EvaluationPeriod = { + start: Date; + end: Date; + label: string; +}; + +type ScoreHistoryProps = { state: ReturnType>>; -}) => { +}; + +const ScoreHistory = ({ state }: ScoreHistoryProps) => { switch (state.type) { case StateType.Loading: case StateType.Error: @@ -160,24 +268,35 @@ const ScoreHistory = ({ } case StateType.Loaded: { - return ; + return ; } } }; -const getScoreHistory = async ([cluster, publisherKey, symbol]: [ - Cluster, - string, - string, -]) => { +const getScoreHistory = async ([ + cluster, + publisherKey, + symbol, + selectedPeriod, +]: [Cluster, string, string, EvaluationPeriod]) => { const url = new URL("/component-score-history", window.location.origin); url.searchParams.set("cluster", ClusterToName[cluster]); url.searchParams.set("publisherKey", publisherKey); url.searchParams.set("symbol", symbol); + url.searchParams.set("from", formatDate(selectedPeriod.start)); + url.searchParams.set("to", formatDate(selectedPeriod.end)); const data = await fetch(url); return scoreHistorySchema.parse(await data.json()); }; +const formatDate = (date: Date) => { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth() + 1; + const day = date.getUTCDate(); + + return `${year.toString()}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}`; +}; + const scoreHistorySchema = z.array( z.strictObject({ time: z.string().transform((value) => new Date(value)), @@ -187,3 +306,283 @@ const scoreHistorySchema = z.array( stalledScore: z.number(), }), ); + +const CHART_HEIGHT = 104; + +type ResolvedScoreHistoryProps = { + scoreHistory: Point[]; +}; + +type Point = { + time: Date; + score: number; + uptimeScore: number; + deviationScore: number; + stalledScore: number; +}; + +const ResolvedScoreHistory = ({ scoreHistory }: ResolvedScoreHistoryProps) => { + const [selectedPoint, setSelectedPoint] = useState( + undefined, + ); + const updateSelectedPoint = useCallback( + (chart: CategoricalChartState) => { + setSelectedPoint( + (chart.activePayload as { payload: Point }[] | undefined)?.[0]?.payload, + ); + }, + [setSelectedPoint], + ); + const currentPoint = useMemo( + () => selectedPoint ?? scoreHistory.at(-1), + [selectedPoint, scoreHistory], + ); + const dateFormatter = useDateFormatter({ + dateStyle: "long", + timeZone: "UTC", + }); + const numberFormatter = useNumberFormatter({ maximumFractionDigits: 4 }); + + const [hoveredScore, setHoveredScore] = useState( + undefined, + ); + const hoverUptime = useCallback(() => { + setHoveredScore("uptime"); + }, [setHoveredScore]); + const hoverDeviation = useCallback(() => { + setHoveredScore("deviation"); + }, [setHoveredScore]); + const hoverStalled = useCallback(() => { + setHoveredScore("stalled"); + }, [setHoveredScore]); + const hoverFinal = useCallback(() => { + setHoveredScore("final"); + }, [setHoveredScore]); + const clearHover = useCallback(() => { + setHoveredScore(undefined); + }, [setHoveredScore]); + + const [focusedScore, setFocusedScore] = useState( + undefined, + ); + const toggleFocusedScore = useCallback( + (value: typeof focusedScore) => { + setFocusedScore((cur) => (cur === value ? undefined : value)); + }, + [setFocusedScore], + ); + const toggleFocusUptime = useCallback(() => { + toggleFocusedScore("uptime"); + }, [toggleFocusedScore]); + const toggleFocusDeviation = useCallback(() => { + toggleFocusedScore("deviation"); + }, [toggleFocusedScore]); + const toggleFocusStalled = useCallback(() => { + toggleFocusedScore("stalled"); + }, [toggleFocusedScore]); + const toggleFocusFinal = useCallback(() => { + toggleFocusedScore("final"); + }, [toggleFocusedScore]); + + return ( + <> +
+
+
+

+ +

+
+
+ } + > + + + <>} /> + + + + + + + + + +
+

+ Score details for{" "} + {currentPoint && dateFormatter.format(currentPoint.time)} +

+ + ), + weight: "40%", + score: numberFormatter.format(currentPoint?.uptimeScore ?? 0), + }, + }, + { + id: "deviation", + onHoverStart: hoverDeviation, + onHoverEnd: clearHover, + onAction: toggleFocusDeviation, + data: { + metric: ( + + ), + weight: "40%", + score: numberFormatter.format(currentPoint?.deviationScore ?? 0), + }, + }, + { + id: "staleness", + onHoverStart: hoverStalled, + onHoverEnd: clearHover, + onAction: toggleFocusStalled, + data: { + metric: ( + + ), + weight: "20%", + score: numberFormatter.format(currentPoint?.stalledScore ?? 0), + }, + }, + { + id: "final", + onHoverStart: hoverFinal, + onHoverEnd: clearHover, + onAction: toggleFocusFinal, + data: { + metric: ( + + ), + weight: undefined, + score: numberFormatter.format(currentPoint?.score ?? 0), + }, + }, + ]} + /> + + ); +}; + +type ScoreComponent = "uptime" | "deviation" | "stalled" | "final"; + +const SCORE_COMPONENT_TO_LABEL = { + uptime: "Uptime Score", + deviation: "Deviation Score", + stalled: "Stalled Score", + final: "Final Score", +} as const; + +const MainChartLabel = ({ + component, +}: { + component: ScoreComponent | undefined; +}) => `${component ? SCORE_COMPONENT_TO_LABEL[component] : "Score"} History`; + +type MetricProps = { + name: ReactNode; + description: string; + component: string; +}; + +const Metric = ({ name, description, component }: MetricProps) => ( +
+
+ + + + {name} +
+
{description}
+
+); diff --git a/apps/insights/src/components/PriceComponentsCard/index.tsx b/apps/insights/src/components/PriceComponentsCard/index.tsx index ccddaae564..c286a75e62 100644 --- a/apps/insights/src/components/PriceComponentsCard/index.tsx +++ b/apps/insights/src/components/PriceComponentsCard/index.tsx @@ -37,14 +37,14 @@ import { Status as StatusComponent } from "../Status"; const SCORE_WIDTH = 32; -type Props = { +type Props = { className?: string | undefined; - priceComponents: PriceComponent[]; + priceComponents: T[]; metricsTime?: Date | undefined; nameLoadingSkeleton: ReactNode; label: string; searchPlaceholder: string; - onPriceComponentAction: (component: PriceComponent) => void; + onPriceComponentAction: (component: T) => void; }; type PriceComponent = { @@ -62,11 +62,11 @@ type PriceComponent = { nameAsString: string; }; -export const PriceComponentsCard = ({ +export const PriceComponentsCard = ({ priceComponents, onPriceComponentAction, ...props -}: Props) => ( +}: Props) => ( }> ); -export const ResolvedPriceComponentsCard = ({ +export const ResolvedPriceComponentsCard = ({ priceComponents, onPriceComponentAction, ...props -}: Props) => { +}: Props) => { const logger = useLogger(); const collator = useCollator(); const filter = useFilter({ sensitivity: "base", usage: "search" }); @@ -278,8 +278,8 @@ export const ResolvedPriceComponentsCard = ({ ); }; -type PriceComponentsCardProps = Pick< - Props, +type PriceComponentsCardProps = Pick< + Props, | "className" | "metricsTime" | "nameLoadingSkeleton" @@ -309,14 +309,14 @@ type PriceComponentsCardProps = Pick< } ); -export const PriceComponentsCardContents = ({ +export const PriceComponentsCardContents = ({ className, metricsTime, nameLoadingSkeleton, label, searchPlaceholder, ...props -}: PriceComponentsCardProps) => { +}: PriceComponentsCardProps) => { const collator = useCollator(); return ( Read more @@ -511,7 +511,7 @@ const otherColumns = ({
, - metric: ( - } - description="Percentage of time a publisher is available and active" - /> - ), - score: numberFormatter.format(currentPoint?.uptimeScore ?? 0), - }, - }, - { - id: "deviation", - onHoverStart: hoverDeviation, - onHoverEnd: clearHover, - onAction: toggleFocusDeviation, - data: { - legend:
, - metric: ( - } - description="Deviations that occur between a publishers' price and the aggregate price" - /> - ), - score: numberFormatter.format( - currentPoint?.deviationScore ?? 0, - ), - }, - }, - { - id: "staleness", - onHoverStart: hoverStalled, - onHoverEnd: clearHover, - onAction: toggleFocusStalled, - data: { - legend:
, - metric: ( - } - description="Penalizes publishers reporting the same value for the price" - /> - ), - score: numberFormatter.format(currentPoint?.stalledScore ?? 0), - }, - }, - { - id: "final", - onHoverStart: hoverFinal, - onHoverEnd: clearHover, - onAction: toggleFocusFinal, - data: { - legend:
, - metric: ( - } - description="The aggregate score, calculated by combining the other three score components" - /> - ), - score: numberFormatter.format(currentPoint?.score ?? 0), - }, - }, - ]} - /> - -
- ); -}; - -type HeaderTextProps = { - isMedian?: boolean | undefined; - component: ScoreComponent; -}; - -const Label = ({ isMedian, component }: HeaderTextProps) => { - switch (component) { - case "uptime": { - return `${isMedian ? "Median " : ""}Uptime Score`; - } - case "deviation": { - return `${isMedian ? "Median " : ""}Deviation Score`; - } - case "stalled": { - return `${isMedian ? "Median " : ""}Stalled Score`; - } - case "final": { - return `${isMedian ? "Median " : ""}Final Score`; - } - } -}; - -type ScoreComponent = "uptime" | "deviation" | "stalled" | "final"; - -type CurrentValueProps = { - point: Point; - focusedScore: ScoreComponent | undefined; -}; - -const CurrentValue = ({ point, focusedScore }: CurrentValueProps) => { - const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 4 }); - switch (focusedScore) { - case "uptime": { - return numberFormatter.format(point.uptimeScore); - } - case "deviation": { - return numberFormatter.format(point.deviationScore); - } - case "stalled": { - return numberFormatter.format(point.stalledScore); - } - default: { - return ; - } - } -}; - -type MetricProps = { - name: ReactNode; - description: string; -}; - -const Metric = ({ name, description }: MetricProps) => ( -
-
{name}
-
{description}
-
-); diff --git a/apps/insights/src/services/clickhouse.ts b/apps/insights/src/services/clickhouse.ts index cbdb7a9ca2..9ec505a885 100644 --- a/apps/insights/src/services/clickhouse.ts +++ b/apps/insights/src/services/clickhouse.ts @@ -70,49 +70,71 @@ export const getPublishers = async () => export const getRankingsByPublisher = async (publisherKey: string) => safeQuery(rankingsSchema, { query: ` - SELECT - time, - symbol, - cluster, - publisher, - uptime_score, - deviation_score, - stalled_score, - final_score, - final_rank + WITH first_rankings AS ( + SELECT publisher, symbol, min(time) AS first_ranking_time FROM publisher_quality_ranking - WHERE time = (SELECT max(time) FROM publisher_quality_ranking) - AND publisher = {publisherKey: String} - AND interval_days = 1 - ORDER BY - symbol ASC, - cluster ASC, - publisher ASC - `, + WHERE interval_days = 1 + GROUP BY (publisher, symbol) + ) + + SELECT + time, + symbol, + cluster, + publisher, + first_ranking_time, + uptime_score, + deviation_score, + stalled_score, + final_score, + final_rank + FROM publisher_quality_ranking + JOIN first_rankings + ON first_rankings.publisher = publisher_quality_ranking.publisher + AND first_rankings.symbol = publisher_quality_ranking.symbol + WHERE time = (SELECT max(time) FROM publisher_quality_ranking) + AND publisher = {publisherKey: String} + AND interval_days = 1 + ORDER BY + symbol ASC, + cluster ASC, + publisher ASC + `, query_params: { publisherKey }, }); export const getRankingsBySymbol = async (symbol: string) => safeQuery(rankingsSchema, { query: ` - SELECT - time, - symbol, - cluster, - publisher, - uptime_score, - deviation_score, - stalled_score, - final_score, - final_rank + WITH first_rankings AS ( + SELECT publisher, symbol, min(time) AS first_ranking_time FROM publisher_quality_ranking - WHERE time = (SELECT max(time) FROM publisher_quality_ranking) - AND symbol = {symbol: String} - AND interval_days = 1 - ORDER BY - symbol ASC, - cluster ASC, - publisher ASC + WHERE interval_days = 1 + GROUP BY (publisher, symbol) + ) + + SELECT + time, + symbol, + cluster, + publisher, + first_ranking_time, + uptime_score, + deviation_score, + stalled_score, + final_score, + final_rank + FROM publisher_quality_ranking + JOIN first_rankings + ON first_rankings.publisher = publisher_quality_ranking.publisher + AND first_rankings.symbol = publisher_quality_ranking.symbol + WHERE time = (SELECT max(time) FROM publisher_quality_ranking) + AND symbol = {symbol: String} + AND interval_days = 1 + ORDER BY + symbol ASC, + cluster ASC, + publisher ASC `, query_params: { symbol }, }); @@ -120,6 +142,7 @@ export const getRankingsBySymbol = async (symbol: string) => const rankingsSchema = z.array( z.strictObject({ time: z.string().transform((time) => new Date(time)), + first_ranking_time: z.string().transform((time) => new Date(time)), symbol: z.string(), cluster: z.enum(["pythnet", "pythtest-conformance"]), publisher: z.string(), @@ -182,6 +205,8 @@ export const getFeedScoreHistory = async ( cluster: Cluster, publisherKey: string, symbol: string, + from: string, + to: string, ) => safeQuery( z.array( @@ -195,26 +220,27 @@ export const getFeedScoreHistory = async ( ), { query: ` - SELECT * FROM ( - SELECT - time, - final_score AS score, - uptime_score AS uptimeScore, - deviation_score AS deviationScore, - stalled_score AS stalledScore - FROM publisher_quality_ranking - WHERE publisher = {publisherKey: String} - AND cluster = {cluster: String} - AND symbol = {symbol: String} - ORDER BY time DESC - LIMIT 30 - ) - ORDER BY time ASC - `, + SELECT + time, + final_score AS score, + uptime_score AS uptimeScore, + deviation_score AS deviationScore, + stalled_score AS stalledScore + FROM publisher_quality_ranking + WHERE publisher = {publisherKey: String} + AND cluster = {cluster: String} + AND symbol = {symbol: String} + AND interval_days = 1 + AND time >= toDateTime64({from: String}, 3) + AND time <= toDateTime64({to: String}, 3) + ORDER BY time ASC + `, query_params: { cluster: ClusterToName[cluster], publisherKey, symbol, + from, + to, }, }, );