Demos›Trading Blotter

Trading Blotter

A book of positions folded into sectors, each one totalling its own quantity, exposure and P&L, with a grand total underneath that re-totals the moment you filter. Header bands group the position, P&L and risk columns; expand any position to see the fills behind it.

Loading live grid…

What you are looking at

  • Row grouping
  • Aggregation
  • Pinned rows
  • Column groups
  • Detail rows

Every one of those is a documented option, not demo-only code. The manual has the config for each, and the playground lets you edit a grid like this one live.

The code behind it

The whole demo, exactly as it runs above. It is plain JavaScript against the published packages — createGrid, AllModules and the same options the manual documents. Nothing here is private API.

Trading Blotter — script278 linesshow
const h = React.createElement

// ── The book ──────────────────────────────────────────────────
const SECTORS = {
  Technology: [
    ['AAPL','Apple Inc.'], ['MSFT','Microsoft Corp.'], ['NVDA','NVIDIA Corp.'],
    ['AVGO','Broadcom Inc.'], ['ORCL','Oracle Corp.'], ['CRM','Salesforce Inc.'],
    ['ADBE','Adobe Inc.'], ['AMD','Advanced Micro Devices'], ['INTC','Intel Corp.'],
    ['CSCO','Cisco Systems'], ['TXN','Texas Instruments'], ['QCOM','Qualcomm Inc.'],
  ],
  Financials: [
    ['JPM','JPMorgan Chase'], ['BAC','Bank of America'], ['WFC','Wells Fargo'],
    ['GS','Goldman Sachs'], ['MS','Morgan Stanley'], ['BLK','BlackRock Inc.'],
    ['SCHW','Charles Schwab'], ['AXP','American Express'], ['C','Citigroup Inc.'],
  ],
  Healthcare: [
    ['UNH','UnitedHealth Group'], ['JNJ','Johnson & Johnson'], ['LLY','Eli Lilly & Co.'],
    ['ABBV','AbbVie Inc.'], ['MRK','Merck & Co.'], ['PFE','Pfizer Inc.'],
    ['TMO','Thermo Fisher'], ['ABT','Abbott Laboratories'],
  ],
  Energy: [
    ['XOM','Exxon Mobil'], ['CVX','Chevron Corp.'], ['COP','ConocoPhillips'],
    ['SLB','Schlumberger NV'], ['EOG','EOG Resources'], ['PSX','Phillips 66'],
  ],
  'Consumer Disc.': [
    ['AMZN','Amazon.com Inc.'], ['TSLA','Tesla Inc.'], ['HD','Home Depot'],
    ['MCD','McDonald\'s Corp.'], ['NKE','Nike Inc.'], ['SBUX','Starbucks Corp.'],
    ['LOW','Lowe\'s Companies'],
  ],
  Industrials: [
    ['CAT','Caterpillar Inc.'], ['BA','Boeing Co.'], ['HON','Honeywell Intl.'],
    ['UPS','United Parcel Service'], ['GE','General Electric'], ['LMT','Lockheed Martin'],
    ['DE','Deere & Co.'],
  ],
  'Consumer Staples': [
    ['PG','Procter & Gamble'], ['KO','Coca-Cola Co.'], ['PEP','PepsiCo Inc.'],
    ['COST','Costco Wholesale'], ['WMT','Walmart Inc.'], ['MDLZ','Mondelez Intl.'],
  ],
  Utilities: [
    ['NEE','NextEra Energy'], ['DUK','Duke Energy'], ['SO','Southern Co.'],
    ['AEP','American Electric'], ['D','Dominion Energy'],
  ],
}

const HUE = {
  Technology: 215, Financials: 160, Healthcare: 340, Energy: 25,
  'Consumer Disc.': 280, Industrials: 200, 'Consumer Staples': 120, Utilities: 45,
}

const TRADERS = ['K. Oyelaran','M. Santos','R. Whitfield','A. Haddad','J. Lindqvist','P. Nakamura']
const VENUES = ['NYSE','NASDAQ','ARCA','BATS','IEX']

// Seeded, so the opening book is identical on every load.
let seed = 90210
const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff

const positions = []
let pid = 0
for (const [sector, names] of Object.entries(SECTORS)) {
  for (const [symbol, name] of names) {
    const side = rnd() > 0.26 ? 'Long' : 'Short'
    const qty = (1 + Math.floor(rnd() * 40)) * 100 * (side === 'Short' ? -1 : 1)
    const avgPx = 18 + rnd() * 420
    const lastPx = avgPx * (1 + (rnd() - 0.47) * 0.16)
    const unrealized = (lastPx - avgPx) * qty
    const realized = (rnd() - 0.42) * 14000

    // The fills that built the position — the detail panel's content, and
    // the reason avgPx is what it is.
    const fillCount = 2 + Math.floor(rnd() * 4)
    const fills = []
    for (let f = 0; f < fillCount; f++) {
      fills.push({
        time: String(9 + Math.floor(rnd() * 7)).padStart(2, '0') + ':' +
              String(Math.floor(rnd() * 60)).padStart(2, '0'),
        venue: VENUES[Math.floor(rnd() * VENUES.length)],
        qty: Math.round(qty / fillCount),
        px: avgPx * (1 + (rnd() - 0.5) * 0.02),
      })
    }

    positions.push({
      id: 'P' + (++pid),
      symbol, name, sector, side, qty,
      avgPx, lastPx,
      unrealized,
      realized,
      dayPnl: unrealized * (0.1 + rnd() * 0.5),
      exposure: Math.abs(qty) * lastPx,
      beta: 0.45 + rnd() * 1.5,
      trader: TRADERS[Math.floor(rnd() * TRADERS.length)],
      fills,
    })
  }
}

// ── Formatters ────────────────────────────────────────────────
const usd0 = (v) => {
  const n = Number(v)
  const s = n < 0 ? '-' : ''
  return s + '$' + Math.abs(n).toLocaleString('en-US', { maximumFractionDigits: 0 })
}
const usd2 = (v) => '$' + Number(v).toLocaleString('en-US', {
  minimumFractionDigits: 2, maximumFractionDigits: 2,
})
const qtyFmt = (v) => Number(v).toLocaleString('en-US')
const money = (v) => {
  const n = Number(v)
  if (Math.abs(n) >= 1e6) return (n < 0 ? '-' : '') + '$' + (Math.abs(n) / 1e6).toFixed(2) + 'M'
  if (Math.abs(n) >= 1e3) return (n < 0 ? '-' : '') + '$' + (Math.abs(n) / 1e3).toFixed(1) + 'K'
  return usd0(n)
}

// ── Renderers ─────────────────────────────────────────────────
const instCell = (_v, row) =>
  h('div', { className: 'inst' },
    h('div', {
      className: 'inst-tag',
      style: { background: 'hsl(' + (HUE[row.sector] ?? 220) + ' 62% 46%)' },
    }, row.symbol.slice(0, 4)),
    h('div', { className: 'inst-text' },
      h('span', { className: 'inst-sym' }, row.symbol),
      h('span', { className: 'inst-name' }, row.name),
    ),
  )

const sideCell = (v) =>
  h('span', { className: 'side side-' + String(v).toLowerCase() }, String(v).toUpperCase())

// One renderer for every signed-money column, including the group and
// grand-total rows: those pass the AGGREGATED number through the same
// column config, so the totals are coloured by sign exactly like a leaf.
const pnlCell = (v) => {
  const n = Number(v)
  if (!Number.isFinite(n)) return h('span', { className: 'pnl-val' }, '')
  return h('span', {
    className: 'pnl-val ' + (n >= 0 ? 'pnl-pos' : 'pnl-neg'),
  }, money(n))
}

const fillsPanel = ({ row }) =>
  h('div', { className: 'fills' },
    h('div', { className: 'fills-title' },
      row.symbol + ' — ' + row.fills.length + ' fills · ' + row.trader),
    h('table', { className: 'fills-table' },
      h('thead', null,
        h('tr', null,
          h('th', null, 'Venue'),
          h('th', null, 'Time'),
          h('th', { className: 'fills-num' }, 'Qty'),
          h('th', { className: 'fills-num' }, 'Price'),
          h('th', { className: 'fills-num' }, 'Value'),
        ),
      ),
      h('tbody', null,
        row.fills.map((f, i) =>
          h('tr', { key: i },
            h('td', null, f.venue),
            h('td', null, f.time),
            h('td', { className: 'fills-num' }, qtyFmt(f.qty)),
            h('td', { className: 'fills-num' }, usd2(f.px)),
            h('td', { className: 'fills-num' }, usd0(f.qty * f.px)),
          ),
        ),
      ),
    ),
  )

// ── Columns, as header bands ──────────────────────────────────
// A group holds the real ColumnDef objects, not field-name strings, so
// renaming a column is a rename the compiler follows rather than a column
// silently dropping out of its band.
const columns = [
  { field: 'symbol', header: 'Instrument',
    size: { width: 200 },
    display: { pinned: 'left', cellRenderer: instCell, tooltip: (_v, r) => r.name },
    filter: { type: 'text' } },

  { field: 'sector', header: 'Sector', size: { width: 150 }, filter: { type: 'set' } },

  {
    header: 'Position',
    children: [
      { field: 'side', header: 'Side',
        size: { width: 86 },
        display: { cellRenderer: sideCell },
        filter: { type: 'set' } },
      { field: 'qty', header: 'Quantity',
        size: { width: 110 },
        display: { formatter: qtyFmt, cellClass: 'num' },
        value: { aggregate: 'sum' },
        filter: { type: 'number' } },
      { field: 'avgPx', header: 'Avg Price',
        size: { width: 110 },
        display: { formatter: usd2, cellClass: 'num' } },
      { field: 'lastPx', header: 'Last',
        size: { width: 110 },
        display: { formatter: usd2, cellClass: 'num' } },
    ],
  },

  {
    header: 'P&L',
    children: [
      { field: 'dayPnl', header: 'Day',
        size: { width: 118 },
        display: { cellRenderer: pnlCell, cellClass: 'pnl',
                   exportFormatter: (v) => Number(v).toFixed(2) },
        value: { aggregate: 'sum' } },
      { field: 'unrealized', header: 'Unrealized',
        size: { width: 128 },
        display: { cellRenderer: pnlCell, cellClass: 'pnl',
                   exportFormatter: (v) => Number(v).toFixed(2) },
        value: { aggregate: 'sum' } },
      { field: 'realized', header: 'Realized',
        size: { width: 118 },
        display: { cellRenderer: pnlCell, cellClass: 'pnl',
                   exportFormatter: (v) => Number(v).toFixed(2) },
        value: { aggregate: 'sum' } },
    ],
  },

  {
    header: 'Risk',
    children: [
      { field: 'exposure', header: 'Exposure',
        size: { width: 124 },
        display: { formatter: money, cellClass: 'num' },
        value: { aggregate: 'sum' } },
      { field: 'beta', header: 'Beta',
        size: { width: 92 },
        display: { formatter: (v) => Number(v).toFixed(2), cellClass: 'num' },
        // Averaged, not summed: a book's beta is not the sum of its
        // positions' betas, and a column with no aggregate would render
        // blank rather than wrong.
        value: { aggregate: 'avg' } },
    ],
  },

  { field: 'trader', header: 'Trader', size: { width: 150 }, filter: { type: 'set' } },
]

let api = null

const grid = createGrid({
  modules: [AllModules],
  columns,
  data: positions,
  rowId: 'id',
  sorting: { enabled: true, multiSort: true },
  filtering: { enabled: true, searchBar: true, searchPlaceholder: 'Search the book…' },
  grouping: {
    model: ['sector'],
    expandedByDefault: 1,
    showCounts: true,
    // A grand total over every FILTERED leaf — so it re-totals as soon as
    // anyone filters, rather than being a row someone computed once.
    grandTotal: 'bottom',
  },
  rowDetail: {
    renderer: fillsPanel,
    estimatedHeight: 150,
  },
  selection: { mode: 'multiple', showCheckboxes: true },
  appearance: { rowHeight: 44, headerHeight: 36, rowStriping: false },
  export: { filename: 'elitegrid-blotter', scope: 'filtered' },
  events: {
    onReady: (a) => {
      api = a
      ready({ rowCount: positions.length })
    },
  },
})

createRoot(mountEl).render(h(Grid, { grid }))

onMessage('EXPORT_CSV', () =>
  api && api.exportCSV({ filename: 'elitegrid-blotter', scope: 'filtered' }))
Trading Blotter — stylescell renderers and layoutshow
.inst { display: flex; align-items: center; gap: 9px; min-width: 0; }
.inst-tag {
  width: 30px; height: 22px; border-radius: 4px; flex-shrink: 0;
  display: grid; place-items: center;
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.625rem; font-weight: 600; color: #fff;
}
.inst-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
.inst-sym { font-weight: 600; font-size: 0.8125rem; }
.inst-name {
  font-size: 0.6875rem; color: var(--eg-muted-text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* P&L reads as a column of signed numbers, so colour carries the sign and
   the digits stay aligned. */
.yg-cell.pnl { justify-content: flex-end; }
.pnl-val {
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.8125rem; font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.pnl-pos { color: #16a34a; }
.pnl-neg { color: #dc2626; }
[data-theme='dark'] .pnl-pos { color: #4ade80; }
[data-theme='dark'] .pnl-neg { color: #f87171; }

.side {
  font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.04em;
  padding: 2px 6px; border-radius: 4px;
}
.side-long  { color: #0369a1; background: rgba(3,105,161,0.10); }
.side-short { color: #b45309; background: rgba(180,83,9,0.10); }
[data-theme='dark'] .side-long  { color: #7dd3fc; background: rgba(125,211,252,0.12); }
[data-theme='dark'] .side-short { color: #fcd34d; background: rgba(252,211,77,0.12); }

/* ── Detail panel: the fills behind a position ────────────── */
.fills { padding: 14px 18px; background: var(--eg-row-striped-bg); }
.fills-title {
  font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--eg-muted-text); margin-bottom: 8px;
}
.fills-table { width: 100%; border-collapse: collapse; }
.fills-table th {
  text-align: left; font-size: 0.6875rem; font-weight: 600;
  letter-spacing: 0.04em; text-transform: uppercase;
  color: var(--eg-muted-text); padding: 4px 10px 4px 0;
}
.fills-table td {
  font-family: 'JetBrains Mono', monospace; font-size: 0.75rem;
  color: var(--eg-cell-text); padding: 3px 10px 3px 0;
  border-top: 1px solid var(--eg-border);
  font-variant-numeric: tabular-nums;
}
.fills-table td:first-child { font-family: 'DM Sans', sans-serif; }
.fills-num { text-align: right; }