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
85 changes: 76 additions & 9 deletions apps/insights/src/app/historical-prices/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,84 @@
import type { NextRequest } from "next/server";
import { z } from "zod";

import { getHistoricalPrices } from "../../services/clickhouse";

const queryParamsSchema = z.object({
symbol: z.string().transform(decodeURIComponent),
publisher: z
.string()
.nullable()
.transform((value) => value ?? undefined),
from: z.string().transform(Number),
to: z.string().transform(Number),
resolution: z.enum(["1s", "1m", "5m", "1H", "1D"]).transform((value) => {
switch (value) {
case "1s": {
return "1 SECOND";
}
case "1m": {
return "1 MINUTE";
}
case "5m": {
return "5 MINUTE";
}
case "1H": {
return "1 HOUR";
}
case "1D": {
return "1 DAY";
}
}
}),
});

export async function GET(req: NextRequest) {
const symbol = req.nextUrl.searchParams.get("symbol");
const until = req.nextUrl.searchParams.get("until");
if (symbol && until) {
const res = await getHistoricalPrices({
symbol: decodeURIComponent(symbol),
until,
const parsed = queryParamsSchema.safeParse(
Object.fromEntries(
Object.keys(queryParamsSchema.shape).map((key) => [
key,
req.nextUrl.searchParams.get(key),
]),
),
);
if (!parsed.success) {
return new Response(`Invalid params: ${parsed.error.message}`, {
status: 400,
});
}

const { symbol, publisher, from, to, resolution } = parsed.data;

if (getNumDataPoints(to, from, resolution) > MAX_DATA_POINTS) {
return new Response("Unsupported resolution for date range", {
status: 400,
});
return Response.json(res);
} else {
return new Response("Must provide `symbol` and `until`", { status: 400 });
}

const res = await getHistoricalPrices({
symbol,
from,
to,
publisher,
resolution,
});

return Response.json(res);
}

const MAX_DATA_POINTS = 3000;

type Resolution = "1 SECOND" | "1 MINUTE" | "5 MINUTE" | "1 HOUR" | "1 DAY";
const ONE_MINUTE_IN_SECONDS = 60;
const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS;

const SECONDS_IN_ONE_PERIOD: Record<Resolution, number> = {
"1 SECOND": 1,
"1 MINUTE": ONE_MINUTE_IN_SECONDS,
"5 MINUTE": 5 * ONE_MINUTE_IN_SECONDS,
"1 HOUR": ONE_HOUR_IN_SECONDS,
"1 DAY": 24 * ONE_HOUR_IN_SECONDS,
};

const getNumDataPoints = (from: number, to: number, resolution: Resolution) =>
(to - from) / SECONDS_IN_ONE_PERIOD[resolution];
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { ChartPageLoading as default } from "../../../../components/PriceFeed/chart-page";
export { ChartPageLoading as default } from "../../../../components/PriceFeed/Chart/chart-page";
2 changes: 1 addition & 1 deletion apps/insights/src/app/price-feeds/[slug]/(main)/page.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { ChartPage as default } from "../../../../components/PriceFeed/chart-page";
export { ChartPage as default } from "../../../../components/PriceFeed/Chart/chart-page";

export const revalidate = 3600;
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { Spinner } from "@pythnetwork/component-library/Spinner";

import { Chart } from "./chart";
import styles from "./chart-page.module.scss";
import { getFeed } from "./get-feed";
import { getFeed } from "../get-feed";
import { ChartToolbar } from "./chart-toolbar";

type Props = {
params: Promise<{
Expand All @@ -26,7 +27,7 @@ type ChartPageImplProps =
});

const ChartPageImpl = (props: ChartPageImplProps) => (
<Card title="Chart" className={styles.chartCard}>
<Card title="Chart" className={styles.chartCard} toolbar={<ChartToolbar />}>
<div className={styles.chart}>
{props.isLoading ? (
<div className={styles.spinnerContainer}>
Expand Down
90 changes: 90 additions & 0 deletions apps/insights/src/components/PriceFeed/Chart/chart-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"use client";
import { Select } from "@pythnetwork/component-library/Select";
import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
import { useLogger } from "@pythnetwork/component-library/useLogger";
import { useCallback } from "react";
import type { Key } from "react-aria";

import type { QuickSelectWindow, Resolution } from "./use-chart-toolbar";
import {
QUICK_SELECT_WINDOW_TO_RESOLUTION,
QUICK_SELECT_WINDOWS,
RESOLUTION_TO_QUICK_SELECT_WINDOW,
RESOLUTIONS,
useChartQuickSelectWindow,
useChartResolution,
} from "./use-chart-toolbar";

const ENABLE_RESOLUTION_SELECTOR = false;

export const ChartToolbar = () => {
const logger = useLogger();
const [quickSelectWindow, setQuickSelectWindow] = useChartQuickSelectWindow();
const [resolution, setResolution] = useChartResolution();

const handleResolutionChanged = useCallback(
(resolution: Resolution) => {
setResolution(resolution).catch((error: unknown) => {
logger.error("Failed to update resolution", error);
});
setQuickSelectWindow(RESOLUTION_TO_QUICK_SELECT_WINDOW[resolution]).catch(
(error: unknown) => {
logger.error("Failed to update quick select window", error);
},
);
},
[logger, setResolution, setQuickSelectWindow],
);

const handleQuickSelectWindowChange = useCallback(
(quickSelectWindow: Key) => {
if (!isQuickSelectWindow(quickSelectWindow)) {
throw new TypeError("Invalid quick select window");
}
setQuickSelectWindow(quickSelectWindow).catch((error: unknown) => {
logger.error("Failed to update quick select window", error);
});
setResolution(QUICK_SELECT_WINDOW_TO_RESOLUTION[quickSelectWindow]).catch(
(error: unknown) => {
logger.error("Failed to update resolution", error);
},
);
},
[logger, setQuickSelectWindow, setResolution],
);

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!ENABLE_RESOLUTION_SELECTOR) {
return;
}

return (
<>
<Select
label="Resolution"
hideLabel={true}
options={RESOLUTIONS.map((resolution) => ({
id: resolution,
label: resolution,
}))}
selectedKey={resolution}
onSelectionChange={handleResolutionChanged}
size="sm"
variant="outline"
/>
<SingleToggleGroup
selectedKey={quickSelectWindow}
onSelectionChange={handleQuickSelectWindowChange}
rounded
items={QUICK_SELECT_WINDOWS.map((quickSelectWindow) => ({
id: quickSelectWindow,
children: quickSelectWindow,
}))}
/>
</>
);
};

function isQuickSelectWindow(value: Key): value is QuickSelectWindow {
return QUICK_SELECT_WINDOWS.includes(value as QuickSelectWindow);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
@use "@pythnetwork/component-library/theme";

.chart {
--border-light: #{theme.pallette-color("stone", 300)};
--border-dark: #{theme.pallette-color("steel", 600)};
--chart-background-light: #{theme.pallette-color("white")};
--chart-background-dark: #{theme.pallette-color("steel", 950)};
--border-light: #{theme.pallette-color("stone", 100)};
--border-dark: #{theme.pallette-color("steel", 900)};
--muted-light: #{theme.pallette-color("stone", 700)};
--muted-dark: #{theme.pallette-color("steel", 300)};
--chart-series-primary-light: #{theme.pallette-color("violet", 500)};
--chart-series-primary-dark: #{theme.pallette-color("violet", 400)};
--chart-series-neutral-light: #{theme.pallette-color("stone", 500)};
--chart-series-neutral-dark: #{theme.pallette-color("steel", 300)};
--chart-series-muted-light: #{theme.pallette-color("violet", 100)};
--chart-series-muted-dark: #{theme.pallette-color("violet", 950)};
}
156 changes: 156 additions & 0 deletions apps/insights/src/components/PriceFeed/Chart/chart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { LineData, UTCTimestamp } from "lightweight-charts";

import { mergeData, startOfResolution } from "./chart";

describe("mergeData", () => {
it("merges two arrays with no overlap", () => {
const a: LineData[] = [
{ time: 1000 as UTCTimestamp, value: 10 },
{ time: 2000 as UTCTimestamp, value: 20 },
];
const b: LineData[] = [
{ time: 3000 as UTCTimestamp, value: 30 },
{ time: 4000 as UTCTimestamp, value: 40 },
];

const result = mergeData(a, b);

expect(result).toEqual([
{ time: 1000, value: 10 },
{ time: 2000, value: 20 },
{ time: 3000, value: 30 },
{ time: 4000, value: 40 },
]);
});

it("deduplicates by time, keeping the second value", () => {
const a: LineData[] = [
{ time: 1000 as UTCTimestamp, value: 10 },
{ time: 2000 as UTCTimestamp, value: 20 },
];
const b: LineData[] = [
{ time: 2000 as UTCTimestamp, value: 99 },
{ time: 3000 as UTCTimestamp, value: 30 },
];

const result = mergeData(a, b);

expect(result).toEqual([
{ time: 1000, value: 10 },
{ time: 2000, value: 99 }, // second value overwrites first
{ time: 3000, value: 30 },
]);
});

it("sorts the merged array by time", () => {
const a: LineData[] = [
{ time: 3000 as UTCTimestamp, value: 30 },
{ time: 1000 as UTCTimestamp, value: 10 },
];
const b: LineData[] = [
{ time: 4000 as UTCTimestamp, value: 40 },
{ time: 2000 as UTCTimestamp, value: 20 },
];

const result = mergeData(a, b);

expect(result).toEqual([
{ time: 1000, value: 10 },
{ time: 2000, value: 20 },
{ time: 3000, value: 30 },
{ time: 4000, value: 40 },
]);
});

it("handles empty first array", () => {
const a: LineData[] = [];
const b: LineData[] = [
{ time: 1000 as UTCTimestamp, value: 10 },
{ time: 2000 as UTCTimestamp, value: 20 },
];

const result = mergeData(a, b);

expect(result).toEqual([
{ time: 1000, value: 10 },
{ time: 2000, value: 20 },
]);
});

it("handles empty second array", () => {
const a: LineData[] = [
{ time: 1000 as UTCTimestamp, value: 10 },
{ time: 2000 as UTCTimestamp, value: 20 },
];
const b: LineData[] = [];

const result = mergeData(a, b);

expect(result).toEqual([
{ time: 1000, value: 10 },
{ time: 2000, value: 20 },
]);
});

it("handles both arrays empty", () => {
const a: LineData[] = [];
const b: LineData[] = [];

const result = mergeData(a, b);

expect(result).toEqual([]);
});
});

describe("startOfResolution", () => {
it("returns start of second for 1s resolution", () => {
const date = new Date("2024-01-15T10:30:45.678Z");
const result = startOfResolution(date, "1s");

expect(result).toBe(new Date("2024-01-15T10:30:45.000Z").getTime());
});

it("returns start of minute for 1m resolution", () => {
const date = new Date("2024-01-15T10:30:45.678Z");
const result = startOfResolution(date, "1m");

expect(result).toBe(new Date("2024-01-15T10:30:00.000Z").getTime());
});

it("returns start of minute for 5m resolution", () => {
const date = new Date("2024-01-15T10:30:45.678Z");
const result = startOfResolution(date, "5m");

expect(result).toBe(new Date("2024-01-15T10:30:00.000Z").getTime());
});

it("returns start of hour for 1H resolution", () => {
const date = new Date("2024-01-15T10:30:45.678Z");
const result = startOfResolution(date, "1H");

expect(result).toBe(new Date("2024-01-15T10:00:00.000Z").getTime());
});

it("returns start of day for 1D resolution", () => {
const date = new Date("2024-01-15T10:30:45.678Z");
const result = startOfResolution(date, "1D");

expect(result).toBe(new Date("2024-01-15T00:00:00.000Z").getTime());
});

it("throws error for unknown resolution", () => {
const date = new Date("2024-01-15T10:30:45.678Z");

expect(() => startOfResolution(date, "15m")).toThrow(
"Unknown resolution: 15m",
);
});

it("throws error for invalid resolution", () => {
const date = new Date("2024-01-15T10:30:45.678Z");

expect(() => startOfResolution(date, "invalid")).toThrow(
"Unknown resolution: invalid",
);
});
});
Loading
Loading