Skip to content

Commit 3f21753

Browse files
authored
Merge pull request #3117 from pyth-network/cprussin/dont-tick-offline-feeds
fix(insights): when feeds aren't trading, show last valid price
2 parents 390f924 + f4ac640 commit 3f21753

File tree

4 files changed

+136
-55
lines changed

4 files changed

+136
-55
lines changed

apps/insights/src/components/LivePrices/index.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { PlusMinus } from "@phosphor-icons/react/dist/ssr/PlusMinus";
44
import type { PriceData, PriceComponent } from "@pythnetwork/client";
5+
import { PriceStatus } from "@pythnetwork/client";
56
import { Skeleton } from "@pythnetwork/component-library/Skeleton";
67
import type { ReactNode } from "react";
78
import { useMemo } from "react";
@@ -39,9 +40,13 @@ const LiveAggregatePrice = ({
3940
cluster: Cluster;
4041
}) => {
4142
const { prev, current } = useLivePriceData(cluster, feedKey);
42-
return (
43-
<Price current={current?.aggregate.price} prev={prev?.aggregate.price} />
44-
);
43+
if (current === undefined) {
44+
return <Price />;
45+
} else if (current.status === PriceStatus.Trading) {
46+
return <Price current={current.price} prev={prev?.price} />;
47+
} else {
48+
return <Price current={current.previousPrice} />;
49+
}
4550
};
4651

4752
const LiveComponentPrice = ({
@@ -101,7 +106,16 @@ const LiveAggregateConfidence = ({
101106
cluster: Cluster;
102107
}) => {
103108
const { current } = useLivePriceData(cluster, feedKey);
104-
return <Confidence confidence={current?.aggregate.confidence} />;
109+
return (
110+
<Confidence
111+
confidence={
112+
current &&
113+
(current.status === PriceStatus.Trading
114+
? current.confidence
115+
: current.previousConfidence)
116+
}
117+
/>
118+
);
105119
};
106120

107121
const LiveComponentConfidence = ({
@@ -153,7 +167,13 @@ export const LiveLastUpdated = ({
153167
});
154168
const formattedTimestamp = useMemo(() => {
155169
if (current) {
156-
const timestamp = new Date(Number(current.timestamp * 1000n));
170+
const timestamp = new Date(
171+
Number(
172+
(current.status === PriceStatus.Trading
173+
? current.timestamp
174+
: current.previousTimestamp) * 1000n,
175+
),
176+
);
157177
return isToday(timestamp)
158178
? formatterWithoutDate.format(timestamp)
159179
: formatterWithDate.format(timestamp);

apps/insights/src/components/PriceFeed/Chart/chart.tsx

Lines changed: 93 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { PriceStatus } from "@pythnetwork/client";
34
import { useLogger } from "@pythnetwork/component-library/useLogger";
45
import { useResizeObserver, useMountEffect } from "@react-hookz/web";
56
import {
@@ -12,7 +13,9 @@ import type {
1213
IChartApi,
1314
ISeriesApi,
1415
LineData,
16+
Time,
1517
UTCTimestamp,
18+
WhitespaceData,
1619
} from "lightweight-charts";
1720
import {
1821
AreaSeries,
@@ -68,6 +71,12 @@ const useChartElem = (symbol: string, feedId: string) => {
6871
const isBackfilling = useRef(false);
6972
const priceFormatter = usePriceFormatter();
7073
const abortControllerRef = useRef<AbortController | undefined>(undefined);
74+
// Lightweight charts has [a
75+
// bug](https://github.com/tradingview/lightweight-charts/issues/1649) where
76+
// it does not properly return whitespace data back to us. So we use this ref
77+
// to manually keep track of whitespace data so we can merge it at the
78+
// appropriate times.
79+
const whitespaceData = useRef<Set<WhitespaceData>>(new Set());
7180

7281
const { current: livePriceData } = useLivePriceData(Cluster.Pythnet, feedId);
7382

@@ -81,44 +90,58 @@ const useChartElem = (symbol: string, feedId: string) => {
8190
return;
8291
}
8392

84-
// Update last data point
85-
const { price, confidence } = livePriceData.aggregate;
8693
const timestampMs = startOfResolution(
8794
new Date(Number(livePriceData.timestamp) * 1000),
8895
resolution,
8996
);
9097

9198
const time = (timestampMs / 1000) as UTCTimestamp;
9299

93-
const priceData: LineData = { time, value: price };
94-
const confidenceHighData: LineData = { time, value: price + confidence };
95-
const confidenceLowData: LineData = { time, value: price - confidence };
100+
if (livePriceData.status === PriceStatus.Trading) {
101+
// Update last data point
102+
const { price, confidence } = livePriceData.aggregate;
96103

97-
const lastDataPoint = chartRef.current.price.data().at(-1);
104+
const priceData: LineData = { time, value: price };
105+
const confidenceHighData: LineData = { time, value: price + confidence };
106+
const confidenceLowData: LineData = { time, value: price - confidence };
98107

99-
if (lastDataPoint && lastDataPoint.time > priceData.time) {
100-
return;
101-
}
108+
const lastDataPoint = mergeData(chartRef.current.price.data(), [
109+
...whitespaceData.current,
110+
]).at(-1);
111+
112+
if (lastDataPoint && lastDataPoint.time > priceData.time) {
113+
return;
114+
}
102115

103-
chartRef.current.confidenceHigh.update(confidenceHighData);
104-
chartRef.current.confidenceLow.update(confidenceLowData);
105-
chartRef.current.price.update(priceData);
116+
chartRef.current.confidenceHigh.update(confidenceHighData);
117+
chartRef.current.confidenceLow.update(confidenceLowData);
118+
chartRef.current.price.update(priceData);
119+
} else {
120+
chartRef.current.price.update({ time });
121+
chartRef.current.confidenceHigh.update({ time });
122+
chartRef.current.confidenceLow.update({ time });
123+
whitespaceData.current.add({ time });
124+
}
106125
}, [livePriceData, resolution]);
107126

108127
function maybeResetVisibleRange() {
109128
if (chartRef.current === undefined || didResetVisibleRange.current) {
110129
return;
111130
}
112-
const data = chartRef.current.price.data();
113-
const first = data.at(0);
114-
const last = data.at(-1);
115-
if (!first || !last) {
116-
return;
131+
const data = mergeData(chartRef.current.price.data(), [
132+
...whitespaceData.current,
133+
]);
134+
if (data.length > 0) {
135+
const first = data.at(0);
136+
const last = data.at(-1);
137+
if (!first || !last) {
138+
return;
139+
}
140+
chartRef.current.chart
141+
.timeScale()
142+
.setVisibleRange({ from: first.time, to: last.time });
143+
didResetVisibleRange.current = true;
117144
}
118-
chartRef.current.chart
119-
.timeScale()
120-
.setVisibleRange({ from: first.time, to: last.time });
121-
didResetVisibleRange.current = true;
122145
}
123146

124147
const fetchHistoricalData = useCallback(
@@ -159,37 +182,49 @@ const useChartElem = (symbol: string, feedId: string) => {
159182
// Get the current historical price data
160183
// Note that .data() returns (WhitespaceData | LineData)[], hence the type cast.
161184
// We never populate the chart with WhitespaceData, so the type cast is safe.
162-
const currentHistoricalPriceData =
163-
chartRef.current.price.data() as LineData[];
185+
const currentHistoricalPriceData = chartRef.current.price.data();
164186
const currentHistoricalConfidenceHighData =
165-
chartRef.current.confidenceHigh.data() as LineData[];
187+
chartRef.current.confidenceHigh.data();
166188
const currentHistoricalConfidenceLowData =
167-
chartRef.current.confidenceLow.data() as LineData[];
189+
chartRef.current.confidenceLow.data();
168190

169191
const newHistoricalPriceData = data.map((d) => ({
170192
time: d.time,
171-
value: d.price,
193+
...(d.status === PriceStatus.Trading && {
194+
value: d.price,
195+
}),
172196
}));
173197
const newHistoricalConfidenceHighData = data.map((d) => ({
174198
time: d.time,
175-
value: d.price + d.confidence,
199+
...(d.status === PriceStatus.Trading && {
200+
value: d.price + d.confidence,
201+
}),
176202
}));
177203
const newHistoricalConfidenceLowData = data.map((d) => ({
178204
time: d.time,
179-
value: d.price - d.confidence,
205+
...(d.status === PriceStatus.Trading && {
206+
value: d.price - d.confidence,
207+
}),
180208
}));
181209

182210
// Combine the current and new historical price data
211+
const whitespaceDataAsArray = [...whitespaceData.current];
183212
const mergedPriceData = mergeData(
184-
currentHistoricalPriceData,
213+
mergeData(currentHistoricalPriceData, whitespaceDataAsArray),
185214
newHistoricalPriceData,
186215
);
187216
const mergedConfidenceHighData = mergeData(
188-
currentHistoricalConfidenceHighData,
217+
mergeData(
218+
currentHistoricalConfidenceHighData,
219+
whitespaceDataAsArray,
220+
),
189221
newHistoricalConfidenceHighData,
190222
);
191223
const mergedConfidenceLowData = mergeData(
192-
currentHistoricalConfidenceLowData,
224+
mergeData(
225+
currentHistoricalConfidenceLowData,
226+
whitespaceDataAsArray,
227+
),
193228
newHistoricalConfidenceLowData,
194229
);
195230

@@ -199,6 +234,12 @@ const useChartElem = (symbol: string, feedId: string) => {
199234
chartRef.current.confidenceLow.setData(mergedConfidenceLowData);
200235
maybeResetVisibleRange();
201236
didLoadInitialData.current = true;
237+
238+
for (const point of data) {
239+
if (point.status !== PriceStatus.Trading) {
240+
whitespaceData.current.add({ time: point.time });
241+
}
242+
}
202243
})
203244
.catch((error: unknown) => {
204245
if (error instanceof Error && error.name === "AbortError") {
@@ -252,7 +293,9 @@ const useChartElem = (symbol: string, feedId: string) => {
252293
return;
253294
}
254295
const { from, to } = range;
255-
const first = chartRef.current?.price.data().at(0);
296+
const first = mergeData(chartRef.current?.price.data() ?? [], [
297+
...whitespaceData.current,
298+
]).at(0);
256299

257300
if (!from || !to || !first) {
258301
return;
@@ -344,11 +387,13 @@ const historicalDataSchema = z.array(
344387
timestamp: z.number(),
345388
price: z.number(),
346389
confidence: z.number(),
390+
status: z.nativeEnum(PriceStatus),
347391
})
348392
.transform((d) => ({
349393
time: Number(d.timestamp) as UTCTimestamp,
350394
price: d.price,
351395
confidence: d.confidence,
396+
status: d.status,
352397
})),
353398
);
354399
const priceFormat = {
@@ -451,18 +496,27 @@ const getColors = (container: HTMLDivElement, resolvedTheme: string) => {
451496
/**
452497
* Merge (and sort) two arrays of line data, deduplicating by time
453498
*/
454-
export function mergeData(as: LineData[], bs: LineData[]) {
455-
const unique = new Map<number, LineData>();
499+
export function mergeData(
500+
as: readonly (LineData | WhitespaceData)[],
501+
bs: (LineData | WhitespaceData)[],
502+
) {
503+
const unique = new Map<Time, LineData | WhitespaceData>();
456504

457505
for (const a of as) {
458-
unique.set(a.time as number, a);
506+
unique.set(a.time, a);
459507
}
460508
for (const b of bs) {
461-
unique.set(b.time as number, b);
509+
unique.set(b.time, b);
462510
}
463-
return [...unique.values()].sort(
464-
(a, b) => (a.time as number) - (b.time as number),
465-
);
511+
return [...unique.values()].sort((a, b) => {
512+
if (typeof a.time === "number" && typeof b.time === "number") {
513+
return a.time - b.time;
514+
} else {
515+
throw new TypeError(
516+
"Invariant failed: unexpected time type encountered, all time values must be of type UTCTimestamp",
517+
);
518+
}
519+
});
466520
}
467521

468522
/**

apps/insights/src/components/PriceFeedChangePercent/index.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { PriceStatus } from "@pythnetwork/client";
34
import { StateType, useData } from "@pythnetwork/component-library/useData";
45
import type { ComponentProps } from "react";
56
import { createContext, use } from "react";
@@ -109,15 +110,19 @@ const PriceFeedChangePercentLoaded = ({
109110
}: PriceFeedChangePercentLoadedProps) => {
110111
const { current } = useLivePriceData(Cluster.Pythnet, feedKey);
111112

112-
return current === undefined ? (
113-
<ChangePercent className={className} isLoading />
114-
) : (
115-
<ChangePercent
116-
className={className}
117-
currentValue={current.aggregate.price}
118-
previousValue={priorPrice}
119-
/>
120-
);
113+
if (current === undefined) {
114+
return <ChangePercent className={className} isLoading />;
115+
} else if (current.status === PriceStatus.Trading) {
116+
return (
117+
<ChangePercent
118+
className={className}
119+
currentValue={current.aggregate.price}
120+
previousValue={priorPrice}
121+
/>
122+
);
123+
} else {
124+
return "-";
125+
}
121126
};
122127

123128
class YesterdaysPricesNotInitializedError extends Error {

apps/insights/src/services/clickhouse.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import "server-only";
22

33
import { createClient } from "@clickhouse/client";
4+
import { PriceStatus } from "@pythnetwork/client";
45
import type { ZodSchema, ZodTypeDef } from "zod";
56
import { z } from "zod";
67

@@ -366,11 +367,12 @@ export const getHistoricalPrices = async ({
366367
timestamp: z.number(),
367368
price: z.number(),
368369
confidence: z.number(),
370+
status: z.nativeEnum(PriceStatus),
369371
}),
370372
),
371373
{
372374
query: `
373-
SELECT toUnixTimestamp(toStartOfInterval(publishTime, INTERVAL ${resolution})) AS timestamp, avg(price) AS price, avg(confidence) AS confidence
375+
SELECT toUnixTimestamp(toStartOfInterval(publishTime, INTERVAL ${resolution})) AS timestamp, avg(price) AS price, avg(confidence) AS confidence, status
374376
FROM prices
375377
PREWHERE
376378
cluster = {cluster: String}
@@ -380,7 +382,7 @@ export const getHistoricalPrices = async ({
380382
WHERE
381383
publishTime >= toDateTime({from: UInt32})
382384
AND publishTime < toDateTime({to: UInt32})
383-
GROUP BY timestamp
385+
GROUP BY timestamp, status
384386
ORDER BY timestamp ASC
385387
`,
386388
query_params: queryParams,

0 commit comments

Comments
 (0)