# Example: Custom aggregator: shares-weighted average price

A mergeable init/accumulate/merge/finalize reducer computes a VWAP per group, shown beside the built-in avg preset on the same data so the two numbers visibly diverge.

Source: https://pretable.ai/examples/weighted-average-aggregator.md

```tsx WeightedAverageGrid.tsx
"use client";

import { useState, type ComponentProps } from "react";

import { PretableSurface } from "@pretable/react";

import { columns } from "./columns";
import { positions } from "./data";

const VIEWPORT_HEIGHT = 340;

export function WeightedAverageGrid() {
  // Grouped by desk from the start, but the query stays controlled so the
  // grouping panel can still regroup or ungroup interactively.
  const [query, setQuery] = useState<
    NonNullable<
      ComponentProps<
        typeof PretableSurface<(typeof positions)[number]>
      >["query"]
    >
  >({
    filters: [],
    sort: [],
    rowGroups: [{ columnId: "desk", direction: "asc" }],
  });

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        Grouped by desk. <strong>Avg price</strong> is the built-in{" "}
        <code>&quot;avg&quot;</code> preset; <strong>VWAP</strong> is the custom
        reducer above, weighted by <strong>Shares</strong> — compare the two
        numbers on a group row to see the weighting actually change the result,
        not just relabel it. Drag <strong>Sector</strong> onto the panel to add
        a second level.
      </p>
      <PretableSurface
        ariaLabel="Positions grouped by desk, comparing plain and weighted average price"
        columns={columns}
        getRowId={(row) => row.id}
        groupPanel={{ enabled: true }}
        onQueryChange={setQuery}
        query={query}
        rows={positions}
        viewportHeight={VIEWPORT_HEIGHT}
      />
    </div>
  );
}
```

```ts columns.ts
import { createColumnHelper } from "@pretable/core";
import type { PretableAggregator } from "@pretable/core";

import type { Position } from "./data";

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});

const count = new Intl.NumberFormat("en-US");

// A mergeable reducer: `init` seeds an accumulator, `accumulate` folds in
// one leaf row's value, `merge` combines two partial accumulators (so the
// tree can aggregate bottom-up), and `finalize` turns the accumulator into
// the displayed output. `row` is the whole leaf row, so a weight can come
// from a different field than the one being averaged.
const weightedAveragePrice: PretableAggregator<
  Position,
  number,
  { weightedTotal: number; weight: number },
  number | null
> = {
  init: () => ({ weightedTotal: 0, weight: 0 }),
  accumulate: (accumulator, value, row) => ({
    weightedTotal: accumulator.weightedTotal + value * row.shares,
    weight: accumulator.weight + row.shares,
  }),
  merge: (left, right) => ({
    weightedTotal: left.weightedTotal + right.weightedTotal,
    weight: left.weight + right.weight,
  }),
  finalize: ({ weightedTotal, weight }) =>
    weight === 0 ? null : weightedTotal / weight,
};

const column = createColumnHelper<Position>();

export const columns = [
  column.accessor("desk", { type: "text", header: "Desk" }),
  column.accessor("sector", { type: "text", header: "Sector" }),
  column.accessor("symbol", { type: "text", header: "Symbol" }),
  column.accessor("shares", {
    type: "number",
    header: "Shares",
    aggregate: "sum",
    format: ({ value }) => count.format(value),
    formatAggregate: ({ value }) =>
      typeof value === "number" ? count.format(value) : "",
  }),
  column.accessor("price", {
    type: "number",
    header: "Price",
    format: ({ value }) => usd.format(value),
  }),
  // Aggregate-only columns: the leaf cell is blank, so a group row is the
  // only place either number appears — that's what makes the divergence
  // between them legible.
  column.accessor("avgPrice", (row) => row.price, {
    type: "number",
    header: "Avg price",
    aggregate: "avg",
    format: () => "—",
    formatAggregate: ({ value }) =>
      typeof value === "number" ? usd.format(value) : "—",
  }),
  column.accessor("vwap", (row) => row.price, {
    type: "number",
    header: "VWAP (wtd by shares)",
    aggregate: weightedAveragePrice,
    format: () => "—",
    formatAggregate: ({ value }) =>
      typeof value === "number" ? usd.format(value) : "—",
  }),
] as const;
```

```ts data.ts
export interface Position {
  id: string;
  desk: string;
  sector: string;
  symbol: string;
  shares: number;
  price: number;
}

/**
 * Each group mixes one very large position with several small ones — a
 * plain arithmetic mean and a shares-weighted mean diverge visibly, which is
 * the whole point: it's proof the custom aggregator did something a preset
 * couldn't, not just algebra on paper.
 */
export const positions: Position[] = [
  {
    id: "p1",
    desk: "Equities",
    sector: "Technology",
    symbol: "NVDA",
    shares: 9000,
    price: 118,
  },
  {
    id: "p2",
    desk: "Equities",
    sector: "Technology",
    symbol: "MSFT",
    shares: 300,
    price: 410,
  },
  {
    id: "p3",
    desk: "Equities",
    sector: "Technology",
    symbol: "AAPL",
    shares: 250,
    price: 227,
  },
  {
    id: "p4",
    desk: "Equities",
    sector: "Healthcare",
    symbol: "LLY",
    shares: 6200,
    price: 780,
  },
  {
    id: "p5",
    desk: "Equities",
    sector: "Healthcare",
    symbol: "UNH",
    shares: 180,
    price: 512,
  },
  {
    id: "p6",
    desk: "Equities",
    sector: "Healthcare",
    symbol: "PFE",
    shares: 220,
    price: 28,
  },
  {
    id: "p7",
    desk: "Credit",
    sector: "Financials",
    symbol: "JPM",
    shares: 7100,
    price: 205,
  },
  {
    id: "p8",
    desk: "Credit",
    sector: "Financials",
    symbol: "GS",
    shares: 140,
    price: 460,
  },
  {
    id: "p9",
    desk: "Credit",
    sector: "Financials",
    symbol: "MS",
    shares: 190,
    price: 98,
  },
  {
    id: "p10",
    desk: "Credit",
    sector: "Energy",
    symbol: "XOM",
    shares: 5400,
    price: 112,
  },
  {
    id: "p11",
    desk: "Credit",
    sector: "Energy",
    symbol: "CVX",
    shares: 210,
    price: 155,
  },
];
```
