# Orders & positions

> An order blotter with status chips, new orders that flash, done orders that linger a moment (createLinger), and positions with realized and unrealized P&L from the fills (createPositions).

An order blotter lists working orders as they arrive, change and complete. It needs a few things beyond the grid itself. Status chips are a `badge` column. A new order flashes once with `flashNewRows`, and footer totals follow the feed. Two helpers cover the rest: `createLinger` keeps a done order on screen a moment, so it does not vanish from under the eye, and `createPositions` keeps each symbol's position from the fills.

New orders below flash as they arrive. A filled order stays 1.5 seconds, dimmed, before it leaves, and each fill moves the position above the grid.

Example: Orders and a position (orders-positions.svelte)

```svelte
<script lang="ts">
  import { Grid, type ColumnDef, type GridRow } from 'bo-grid';
  import { createLinger } from 'bo-grid/realtime';
  import { createPositions, unrealizedPnl } from 'bo-grid/trading';

  type Order = { id: number; symbol: string; side: 'buy' | 'sell'; qty: number; price: number; status: string };
  let nextId = 1;
  const order = (side: Order['side']): Order => ({ id: nextId++, symbol: 'FPT', side, qty: 100 * Math.ceil(Math.random() * 5), price: Math.round((131 + Math.random() * 2) * 10) / 10, status: 'Working' });
  let orders = $state.raw<Order[]>([order('buy'), order('sell'), order('buy')]);

  // A done order stays 1.5 s, dimmed, instead of vanishing from under the eye.
  let expired = $state(0);
  const linger = createLinger<Order, number>({ ms: 1500, key: (o) => o.id, onExpire: () => expired++ });
  const shown = $derived.by(() => {
    void expired; // updates again when a row is due to go
    return linger.update(orders);
  });
  const rowClass = $derived.by(() => {
    void shown; // a new function per update, so the leaving rows restyle
    return (o: GridRow) => (linger.leaving(o as Order) ? 'leaving' : undefined);
  });

  // Positions on average cost, from the fills.
  const book = createPositions();
  let position = $state(book.get('FPT'));

  const columns: ColumnDef[] = [
    { type: 'number', key: 'id', header: '#', width: 56, decimals: 0 },
    { type: 'badge', key: 'side', header: 'Side', width: 80, tones: { buy: 'up', sell: 'down' } },
    { type: 'number', key: 'qty', header: 'Qty', decimals: 0, groupAgg: 'sum' },
    { type: 'price', key: 'price', header: 'Price' },
    { type: 'badge', key: 'status', header: 'Status', flex: 1, tones: { Working: 'info', Filled: 'up' } },
  ];

  $effect(() => {
    const timer = setInterval(() => {
      // A Filled order leaves the list on the next update, and lingers as Filled.
      const open = orders.filter((o) => o.status !== 'Filled');
      const fill = open.find((o) => Math.random() < 0.4);
      if (fill) {
        book.fill({ symbol: fill.symbol, side: fill.side, qty: fill.qty, price: fill.price });
        position = book.get('FPT'); // a copy
        orders = open.map((o) => (o === fill ? { ...o, status: 'Filled' } : o));
      } else {
        orders = open.length < 6 ? [...open, order(Math.random() < 0.6 ? 'buy' : 'sell')] : open;
      }
    }, 900);
    return () => clearInterval(timer);
  });
</script>

<p class="note">
  FPT position <b>{position?.qty ?? 0}</b> at {position ? position.avgPrice.toFixed(2) : '–'} · realized {position?.realized.toFixed(0) ?? 0} · unrealized at 132: {position ? unrealizedPnl(position, 132).toFixed(0) : 0}
</p>
<div class="blotter"><Grid rows={shown} {columns} {rowClass} height={260} footer flashNewRows /></div>

<style>
  .note {
    margin: 0 0 8px;
    font-size: 12px;
  }
  .blotter :global(.leaving) {
    opacity: 0.45;
  }
</style>
```

## The blotter

```ts
const columns: ColumnDef[] = [
  { type: 'text', key: 'symbol', header: 'Symbol' },
  { type: 'badge', key: 'side', header: 'Side', tones: { buy: 'up', sell: 'down' } },
  { type: 'number', key: 'qty', header: 'Qty', groupAgg: 'sum' },
  { type: 'badge', key: 'status', header: 'Status', tones: { Working: 'info', Filled: 'up', Cancelled: 'neutral' } },
];
```

```svelte
<Grid rows={shown} {columns} {rowClass} footer flashNewRows height={480} />
```

- A `badge` column maps each value to a tone: `'up'`, `'down'`, `'amber'`, `'info'` or `'neutral'`.
- `flashNewRows` flashes a row once when its id is new: an order that arrived, not one that changed. See [Live updates](https://bonguynvan.github.io/bo-grid/docs/realtime/#stale-and-new-rows).
- `footer` shows a totals row; each column with a `groupAgg` shows that aggregate over all rows, and it stays live as orders change.

## Orders that linger

When an order completes and leaves your list, `createLinger` keeps it on screen for `ms` more, in place, so nothing else moves until it goes:

```ts
import { createLinger } from 'bo-grid/realtime';

let expired = $state(0);
const linger = createLinger<GridRow, string | number>({ ms: 1500, key: (o) => o.id, onExpire: () => expired++ });
const shown = $derived.by(() => {
  void expired; // updates again when a row is due to go
  return linger.update(orders); // the orders, plus the ones that just left, in place
});
const rowClass = $derived.by(() => {
  void shown; // a new function per update, so the grid restyles the rows leaving
  return (o: GridRow) => (linger.leaving(o) ? 'leaving' : undefined);
});
```

- `update(rows)` returns the rows you give it, plus those that left within `ms`, each right after the row it followed. A row that comes back is simply there again.
- `onExpire` runs when a lingering row is due to go: call `update` again (above, by bumping `expired`).
- Keys must be unique. `leaving(row)` compares the row object as `update` returned it.
- To show an order as Filled while it lingers, mark it Filled first and drop it from your list on the next update: it lingers as it was last shown.
- Rows on their way out count in footer totals like any other. `stop()` drops them and the pending `onExpire`.

## Positions from fills

```ts
import { createPositions, unrealizedPnl } from 'bo-grid/trading';

const book = createPositions();
book.fill({ symbol: 'FPT', side: 'buy', qty: 400, price: 11.5 });
book.fill({ symbol: 'FPT', side: 'sell', qty: 100, price: 13 }); // realizes 150
const p = book.get('FPT'); // { symbol: 'FPT', qty: 300, avgPrice: 11.5, realized: 150, bought: 400, sold: 100 }
if (p) unrealizedPnl(p, 12); // 150: the open 300 marked at 12
```

- Each fill moves the symbol's position on average cost. A fill against the position realizes P&L on the part it closes, at the average price. One that goes through flat opens the rest on the other side, at the fill price.
- `fee` comes off realized P&L; a negative fee (a rebate) adds to it.
- Fractional sizes that add up to flat are flat, with no dust left open.
- A fill with an empty symbol, a side other than `'buy'` or `'sell'`, a quantity not above 0, or a price or fee that is not a number throws, so a bad feed message is seen rather than booked.
- `all()` lists every position, and `clear()` empties the book.

**Live P&L.** Keep the last price as a field of each position row and compute unrealized P&L in a computed column. It depends only on its own row, so a `groupAgg: 'sum'` footer follows every tick:

```ts
{ type: 'number', key: 'upnl', header: 'Unrealized', groupAgg: 'sum',
  value: (r) => unrealizedPnl({ qty: Number(r.qty), avgPrice: Number(r.avgPrice) }, Number(r.last)) }
```

The full blotter: orders with status chips, cancel on C, and positions with live P&L:

Live example: https://bonguynvan.github.io/bo-grid/examples/orders/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/)

## Reference

- `flashNewRows`: `boolean | number` (default `false`) — Flash a row once when it arrives in `rows` (a new order, a new fill): every cell, in the neutral flash colour, for this many ms (`true`: 600). A row arrives when its id is new; a copy of a row already there does not flash, nor do the rows an empty grid gets (a load or a reload), nor a wholesale replacement (more than 64 rows, and more than an eighth of them, at once). Rows on screen only, as with every flash.

  ```svelte
  <Grid {rows} {columns} height={480} flashNewRows={900} />
  ```

- `footer`: `boolean` (default `false`) — Show a pinned totals row: each column with a `groupAgg` shows that aggregate over all (filtered) rows. In-memory mode only.
- `rowClass`: `(row: GridRow) => string | undefined` — Return extra CSS class(es) for a data row (e.g. to colour by value). Style them via `:global(.your-class)` since rows live inside the grid.

  ```svelte
  <Grid {rows} {columns} height={480} rowClass={(row) => (row.status === 'Rejected' ? 'rejected' : undefined)} />
  ```


- `groupAgg`: `AggKind` (default `undefined`) — When grouping is active (or with `footer`), show this aggregate of the column on group headers and the totals row.

  ```ts
  { type: 'volume', key: 'qty', header: 'Qty', groupAgg: 'sum' }
  ```

- `value`: `(row: GridRow) => unknown` — Computed value: derive this cell's value from the whole row instead of reading `row[key]` (KPIs, ratios, deltas). Flows through display, sort, filter, aggregation, export and conditional formatting. `key` still names the column (and is the sort/filter key) but need not be a real row field. Keep it cheap and pure — it's called during sort/filter. Computed columns aren't editable. In-memory mode (a server source owns its own derivations).

  ```ts
  { type: 'percent', key: 'chg', header: 'Chg %', value: (row) => ((Number(row.last) - Number(row.ref)) / Number(row.ref)) * 100 }
  ```


`createLinger<R, K>({ ms, key, onExpire? })` returns `{ update(rows), leaving(row), size, stop() }`.

`createPositions()` returns `{ fill(f), get(symbol), all(), clear() }`; a fill is `{ symbol, side, qty, price, fee? }`, and a position is `{ symbol, qty, avgPrice, realized, bought, sold }` (`qty` below 0 when short). `unrealizedPnl(position, last)` marks the open quantity at `last`.

---
Page: https://bonguynvan.github.io/bo-grid/docs/orders-positions/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt
