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
37 changes: 37 additions & 0 deletions benchmarks/hyperliquid-frontends.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -243,3 +243,40 @@ providers:
success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120))
sample_size: hl_frontend_fills_total_24h_v2{builder="okto"}
series: hl_frontend_effective_fee_bps_v2{builder="okto"}

# Companion metric panels surfaced as a switchable mini leaderboard below
# the main p50/p90/p99 table. Each panel queries one Prom metric per
# provider (builder label) and re-ranks the cohort under its direction.
# All five metrics come from the local hl-node harness and update at the
# same sub-minute cadence as the main headline.
metric_panels:
- id: deviation
label: Slippage proxy
description: "Per fill, the basis-point gap between the executed price and the previous trade price on the same asset, averaged over the rolling 24h window. Lower is better. Frontends that route to less liquid venues or slower order paths show higher deviation, which is direct user cost on top of the fee column."
metric: hl_frontend_price_deviation_bps_v2
unit: bps
higher_is_better: false
- id: outage
label: Time since last fill
description: "Seconds since this builder's most recent attributed fill landed on Hyperliquid. Cohort baseline runs under one minute during active hours; above a few minutes is a real anomaly. Catches frontends whose routing pipeline is down or quietly stopped operating."
metric: hl_frontend_last_fill_age_seconds_v2
unit: s
higher_is_better: false
- id: activity
label: Fills per minute
description: "Activity rate over the rolling 24h window. Higher means more flow currently routes through this frontend. Combine with effective fee bps to read the operational intent: high activity + low fee = aligned, high activity + high fee = extractive."
metric: hl_frontend_fills_per_min_v2
unit: count
higher_is_better: true
- id: taker
label: Taker share
description: "Fraction of fills that crossed the spread (paid the bid ask gap). Aggressive instant execution wallets skew taker heavy, pro terminals with limit order workflows skew maker heavy. Reveals the routing intent embedded in each frontend's UX."
metric: hl_frontend_taker_pct_v2
unit: pct
higher_is_better: false
- id: volume
label: Volume routed
description: "Notional USD routed in the rolling 24h window. Headline volume metric most other HL dashboards publish. Here it is companion, not the headline, because volume is only loosely correlated with how aligned a frontend is with its users."
metric: hl_frontend_volume_usd_24h_v2
unit: usd
higher_is_better: true
7 changes: 7 additions & 0 deletions src/components/benchmark-body.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
import { DistributionChart } from "@/components/distribution-chart";
import { DonutChart } from "@/components/donut-chart";
import { RegionGrid } from "@/components/region-grid";
import { MetricPanelGrid } from "@/components/metric-panel-grid";
import { CountLeaderboard } from "@/components/count-leaderboard";
import { SummaryStat } from "@/components/summary-stat";
import { ViewSwitcher } from "@/components/view-switcher";
Expand DownExpand Up@@ -180,7 +181,7 @@
// they always saw.
const allowedViews = viewsForBenchmark(benchmark);
const defaultView = defaultViewFor(benchmark);
const [view, setView, viewMounted] = useViewPreference(

Check failure on line 184 in src/components/benchmark-body.tsx

View workflow job for this annotation

GitHub Actions/ check

React Hook "useViewPreference" is called conditionally. React Hooks must be called in the exact same order in every component render
benchmark.slug,
defaultView,
allowedViews,
Expand All@@ -191,7 +192,7 @@
// hidden when they switch to distribution or donut - the model is
// "this is the field of providers the reader chose to focus on",
// not "what each view chose to drop". Resets on bench navigation.
const [excluded, setExcluded] = useState<Set<string>>(() => new Set());

Check failure on line 195 in src/components/benchmark-body.tsx

View workflow job for this annotation

GitHub Actions/ check

React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render
const toggleExclude = (slug: string) =>
setExcluded((prev) => {
const next = new Set(prev);
Expand All@@ -207,8 +208,8 @@
// being split between the dimension row and the chart toolbar.
const chartRegions = chartOnlyRegions(benchmark);
const showChartRegionRow = regionOptions.length === 0 && chartRegions.length > 1;
const [chartRegion, setChartRegion] = useState<string>("all");

Check failure on line 211 in src/components/benchmark-body.tsx

View workflow job for this annotation

GitHub Actions/ check

React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
const chartRegionOptions: ChainOption[] = useMemo(

Check failure on line 212 in src/components/benchmark-body.tsx

View workflow job for this annotation

GitHub Actions/ check

React Hook "useMemo" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
() => [
{ value: "all", label: "All" },
...chartRegions.map((r) => ({ value: r, label: REGION_DISPLAY[r] ?? r })),
Expand DownExpand Up@@ -362,6 +363,12 @@
<LedgerTable benchmark={benchmark} />
</div>

{benchmark.metricPanels && benchmark.metricPanels.length > 0 && (
<div className="mt-8 card-soft rounded-xl p-4 sm:p-6 lg:p-8">
<MetricPanelGrid benchmark={benchmark} />
</div>
)}

{benchmark.unit !== "count" &&
Object.keys(benchmark.extras.regions).length > 0 && (
<div className="mt-8 card-soft rounded-xl p-4 sm:p-6 lg:p-8">
Expand Down
179 changes: 179 additions & 0 deletions src/components/metric-panel-grid.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
"use client";

import { useState } from "react";
import type { Benchmark, MetricPanel } from "@/types/benchmark";
import { fmtUnit } from "@/lib/format";

/**
* Switchable mini leaderboard surfaced under the main ledger.
*
* Each panel declares one Prom metric (already fetched server side and
* stored in `benchmark.metricPanels`). The reader toggles between panels;
* the visible table re-ranks the same provider set by the active panel's
* metric, applying its higher_is_better direction.
*
* Renders nothing when `benchmark.metricPanels` is empty, so non-HL
* benches that do not declare any panels stay unaffected.
*/
export function MetricPanelGrid({ benchmark }: { benchmark: Benchmark }) {
const panels = benchmark.metricPanels ?? [];
const [activeId, setActiveId] = useState<string | null>(panels[0]?.id ?? null);

if (panels.length === 0) return null;

const active = panels.find((p) => p.id === activeId) ?? panels[0];

return (
<section className="mt-8">
<header className="mb-3 flex flex-wrap items-baseline justify-between gap-3">
<div>
<h2 className="label-mono text-ink-faint">Companion metrics</h2>
{active.description && (
<p className="mt-1 text-[12px] text-ink-muted max-w-xl">
{active.description}
</p>
)}
</div>
<PanelTabs panels={panels} active={active.id} onSelect={setActiveId} />
</header>

<MetricPanelTable panel={active} benchmark={benchmark} />
</section>
);
}

function PanelTabs({
panels,
active,
onSelect,
}: {
panels: MetricPanel[];
active: string;
onSelect: (id: string) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-1">
{panels.map((p) => {
const on = p.id === active;
return (
<button
key={p.id}
type="button"
onClick={() => onSelect(p.id)}
className={[
"rounded px-2.5 py-1 text-[11px] font-sans tabular uppercase tracking-[0.1em] font-medium transition-colors",
on
? "bg-ink text-paper"
: "text-ink-muted hover:text-ink hover:bg-paper-soft",
].join(" ")}
title={p.metric}
>
{p.label}
</button>
);
})}
</div>
);
}

function MetricPanelTable({
panel,
benchmark,
}: {
panel: MetricPanel;
benchmark: Benchmark;
}) {
// Build rows from the live provider set so unknown/extra slugs in
// panel.values get ignored (defensive against a stale label rotation).
const rows = benchmark.results
.map((r) => ({
slug: r.slug,
name: r.name,
value: panel.values[r.slug],
}))
.filter((r) => Number.isFinite(r.value));

rows.sort((a, b) =>
panel.higherIsBetter ? b.value - a.value : a.value - b.value
);

const noData = benchmark.results.filter(
(r) => !Number.isFinite(panel.values[r.slug])
);

if (rows.length === 0) {
return (
<p className="text-[12px] text-ink-faint italic">
Currently no data for this metric. Will populate within ~1 hour once
the harness fills its rolling window.
</p>
);
}

return (
<div className="overflow-x-auto -mx-4 sm:mx-0 px-4 sm:px-0">
<table className="ledger w-full min-w-full border-collapse">
<thead>
<tr>
<th className="border-y-2 border-ink py-2 pr-3 text-left text-[11px] uppercase tracking-[0.18em]">
Provider
</th>
<th className="border-y-2 border-ink py-2 pl-3 text-right text-[11px] uppercase tracking-[0.18em]">
{panel.label}
</th>
<th
className="border-y-2 border-ink py-2 pl-3 text-right text-[11px] uppercase tracking-[0.18em] hidden md:table-cell"
title="Position vs the others on this metric. Direction depends on the metric (lower better for fees / outages, higher better for activity)."
>
Rank
</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={row.slug} className="border-b border-ink/10">
<td className="py-2 pr-3 text-[13px] font-medium">{row.name}</td>
<td className="py-2 pl-3 text-right text-[13px] tabular">
{formatPanelValue(row.value, panel.unit)}
</td>
<td className="py-2 pl-3 text-right text-[12px] tabular text-ink-muted hidden md:table-cell">
#{i + 1}
</td>
</tr>
))}
{noData.map((p) => (
<tr key={p.slug} className="border-b border-ink/10 text-ink-faint">
<td className="py-2 pr-3 text-[13px]">{p.name}</td>
<td className="py-2 pl-3 text-right text-[12px] italic">no data</td>
<td className="py-2 pl-3 text-right text-[12px] hidden md:table-cell">
{"—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

function formatPanelValue(
value: number,
unit: MetricPanel["unit"]
): string {
if (unit === "pct") {
// Detect both 0..1 fractions and already-scaled percentages.
const v = value > 1 ? value : value * 100;
return `${v.toFixed(1)}%`;
}
if (unit === "count") {
if (value >= 1000) return value.toFixed(0);
if (value >= 10) return value.toFixed(1);
return value.toFixed(2);
}
if (unit === "s") {
if (value >= 3600) return `${(value / 3600).toFixed(1)}h`;
if (value >= 60) return `${(value / 60).toFixed(1)}min`;
return `${value.toFixed(0)}s`;
}
return fmtUnit(value, unit);
}
31 changes: 30 additions & 1 deletion src/lib/spec-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,7 +69,7 @@ const slug = z
/** PromQL. a non-empty string. We don't parse PromQL; that's Prometheus's job. */
const promql = z.string().min(1);

const region = z.enum(["us-east", "eu-west", "ap-southeast", "global"]);
const region = z.enum(["us-east", "eu-west", "ap-southeast", "sgp", "global"]);

const queries = z
.object({
Expand DownExpand Up@@ -274,6 +274,35 @@ export const SpecSchema = z
.optional(),

providers: z.array(provider).min(1),

/**
* Optional companion metrics rendered as switchable mini leaderboards
* below the main table. Each panel declares a single Prometheus metric
* name; the spec loader queries it per provider as
* `<metric>{builder="<slug>"}` and stores the value. Used by the HL
* frontends bench to surface execution quality, outage signal, taker
* share, etc. without changing the main p50 headline.
*/
metric_panels: z
.array(
z.object({
id: z
.string()
.min(1)
.max(40)
.regex(/^[a-z][a-z0-9_]*$/, "Panel id must be lowercase snake_case"),
label: z.string().min(1).max(80),
description: z.string().max(300).optional(),
metric: z.string().min(1).max(200),
/** The PromQL label that holds each provider's slug. Defaults to
* "builder"; other benches may use "provider", "venue", etc. */
label_key: z.string().min(1).max(40).default("builder"),
unit: z.enum(["ms", "s", "pct", "bps", "count", "slots", "usd"]),
higher_is_better: z.boolean().default(false),
})
)
.max(8)
.optional(),
})
.strict();

Expand Down
30 changes: 28 additions & 2 deletions src/lib/spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@ import path from "node:path";
import { cache } from "react";
import { unstable_cache } from "next/cache";
import yaml from "js-yaml";
import type { Benchmark, ProviderResult } from "@/types/benchmark";
import type { Benchmark, MetricPanel, ProviderResult } from "@/types/benchmark";
import { Prometheus } from "@/lib/prometheus";
import { SpecSchema, type Spec } from "@/lib/spec-schema";
import { renderBenchmarkText } from "@/lib/bench-template";
Expand DownExpand Up@@ -516,7 +516,7 @@ function escapePromLabelValue(v: string): string {
async function tryLoadLive(
spec: Spec,
isFiltered = false
): Promise<Pick<Benchmark, "results" | "extras" | "sampleSize" | "lastRunAt"> | null> {
): Promise<Pick<Benchmark, "results" | "extras" | "sampleSize" | "lastRunAt" | "metricPanels"> | null> {
const url = spec.prometheus?.url ?? process.env.PROMETHEUS_URL;
if (!url) return null;
const prom = new Prometheus(url);
Expand DownExpand Up@@ -643,6 +643,31 @@ async function tryLoadLive(
// No live numbers from anyone (every provider was skipped) → draft.
if (liveResults.length === 0) return null;

// Optional companion metric panels. Each panel declares one Prometheus
// metric; we query it per provider (`<metric>{<label_key>="<slug>"}`)
// and store the scalar values. Providers with no data for that metric
// are omitted from the values map (rendered as "no data" by the UI).
const metricPanels: MetricPanel[] = [];
for (const panel of spec.metric_panels ?? []) {
const values: Record<string, number> = {};
await Promise.all(
spec.providers.map(async (p) => {
const q = `${panel.metric}{${panel.label_key}="${escapePromLabelValue(p.slug)}"}`;
const v = await prom.scalar(q);
if (v != null && Number.isFinite(v)) values[p.slug] = v;
})
);
metricPanels.push({
id: panel.id,
label: panel.label,
description: panel.description,
metric: panel.metric,
unit: panel.unit,
higherIsBetter: panel.higher_is_better,
values,
});
}

// Derive lastRunAt from the actual Prom data freshness. We probe the
// first provider that has a p50 query and ask Prom for the age of
// its underlying metric. This is consistent across pages (Prom is the
Expand DownExpand Up@@ -673,6 +698,7 @@ async function tryLoadLive(
Object.keys(seriesByRegion30d).length > 0 ? seriesByRegion30d : undefined,
regions: regions as Benchmark["extras"]["regions"],
},
metricPanels: metricPanels.length > 0 ? metricPanels : undefined,
sampleSize: totalSamples,
lastRunAt,
};
Expand Down
17 changes: 17 additions & 0 deletions src/types/benchmark.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,19 @@ export type RegionPoint = {

export type Series24h = number[];

export type MetricPanel = {
id: string;
label: string;
description?: string;
metric: string;
unit: "ms" | "s" | "pct" | "bps" | "count" | "slots" | "usd";
higherIsBetter: boolean;
/** Per-provider scalar values, keyed by provider slug. Providers with no
* live data for this metric are omitted; the renderer renders them as
* "no data" instead of zero. */
values: Record<string, number>;
};

export type ResultExtras = {
/** 24h-window global series per provider. sparklines + default chart view. */
series24h: Record<string, Series24h>;
Expand DownExpand Up@@ -147,4 +160,8 @@ export type Benchmark = {
methodology: string[];
source: string;
extras: ResultExtras;
/** Optional companion metrics surfaced as switchable mini leaderboards
* below the main table. Populated by the spec loader from
* `metric_panels` in the YAML. */
metricPanels?: MetricPanel[];
};
Loading