# Live Price Monitor

Canonical page: https://www.adaptabletools.com/docs/showcase-demos-live-price-monitor

**Example: Showcase: Live Price Monitor**

Demo project (Vanilla); the files without a marker are shown below:

```text
src/
  adaptableOptions.ts
  agGridModules.ts     [template, with charts]
  columnDefs.ts
  gridOptions.ts
  index.ts             [template]
  livePriceFeed.ts
  onAdaptableReady.ts
  rowData.ts
  styles.css           [template]
index.html             [template]
package.json           [template] + ag-charts-enterprise
tsconfig.json          [template]
```

Files marked `[template]` are the same in every demo; see the [demo template](https://www.adaptabletools.com/llms/demo-template.md#vanilla).

**`src/adaptableOptions.ts`**

```ts
import {AdaptableOptions} from '@adaptabletools/adaptable';
import {LiveQuote} from './rowData';
import {isLiveFeedActive, startLiveFeed, stopLiveFeed} from './livePriceFeed';

export const adaptableOptions: AdaptableOptions<LiveQuote> = {
  primaryKey: 'id',
  adaptableId: 'Showcase: Live Price Monitor',
  userName: 'Desk Trader',
  expressionOptions: {
    liveRefreshIntervalMs: 1000,
  },
  dashboardOptions: {
    customToolbars: [
      {
        name: 'FeedToolbar',
        title: 'Feed',
        toolbarButtons: [
          {
            label: 'Start feed',
            buttonStyle: {tone: 'success', variant: 'raised'},
            disabled: () => isLiveFeedActive(),
            onClick: () => startLiveFeed(),
          },
          {
            label: 'Stop feed',
            buttonStyle: {tone: 'warning', variant: 'outlined'},
            disabled: () => !isLiveFeedActive(),
            onClick: () => stopLiveFeed(),
          },
        ],
      },
    ],
  },
  initialState: {
    Dashboard: {
      Tabs: [
        {
          Name: 'Monitor',
          Toolbars: ['FeedToolbar', 'Layout', 'Alert', 'SystemStatus'],
        },
      ],
      ModuleButtons: ['Layout', 'Alert', 'FlashingCell', 'CalculatedColumn', 'StyledColumn'],
    },
    StatusBar: {
      StatusBars: [
        {
          Key: 'Center Panel',
          StatusBarPanels: ['Alert', 'SystemStatus'],
        },
      ],
    },
    Theme: {CurrentTheme: 'dark'},
    NamedQuery: {
      NamedQueries: [
        {
          Name: 'Active Movers',
          Expression: 'ABS([changePct]) > 0.75',
        },
      ],
    },
    Layout: {
      CurrentLayout: 'Live Monitor',
      Layouts: [
        {
          Name: 'Live Monitor',
          AutoSizeColumns: true,
          TableColumns: [
            'symbol',
            'name',
            'sector',
            'open',
            'last',
            'ticks45s',
            'high5m',
            'trend',
            'changePct',
            'sessionTicks',
            'volume',
          ],
          ColumnSizing: {
            trend: {Width: 52},
            ticks45s: {Width: 88},
            high5m: {Width: 100},
            sessionTicks: {Width: 168},
          },
          ColumnSorts: [{ColumnId: 'changePct', SortOrder: 'Desc'}],
        },
        {
          Name: 'Active Movers',
          AutoSizeColumns: true,
          TableColumns: [
            'symbol',
            'last',
            'ticks45s',
            'high5m',
            'trend',
            'changePct',
            'sessionTicks',
            'volume',
          ],
          ColumnSizing: {
            trend: {Width: 52},
            ticks45s: {Width: 88},
            high5m: {Width: 100},
            sessionTicks: {Width: 168},
          },
          GridFilter: {
            Expression: 'QUERY("Active Movers")',
          },
        },
      ],
    },
    FlashingCell: {
      FlashingCellDefinitions: [
        {
          Name: 'flash-last',
          Type: 'Change',
          Scope: {ColumnIds: ['last']},
          Rule: 'ANY_CHANGE()',
        },
        {
          Name: 'flash-volume',
          Type: 'Change',
          Scope: {ColumnIds: ['volume']},
          Rule: 'ANY_CHANGE()',
        },
      ],
    },
    CalculatedColumn: {
      CalculatedColumns: [
        {
          ColumnId: 'trend',
          FriendlyName: 'Trend',
          Type: 'Standard',
          Expression:
            "CASE WHEN [changePct] > 0 THEN 'Up' WHEN [changePct] < 0 THEN 'Down' ELSE 'Flat' END",
          CalculatedColumnSettings: {
            DataType: 'text',
            Resizable: false,
            Width: 52,
          },
        },
        {
          ColumnId: 'ticks45s',
          FriendlyName: 'Ticks (45s)',
          Type: 'Observable',
          Expression: 'CHANGE_COUNT{[last], WITHIN(45s)}',
          CalculatedColumnSettings: {
            DataType: 'number',
            Width: 88,
          },
        },
        {
          ColumnId: 'high5m',
          FriendlyName: 'High (5m)',
          Type: 'Observable',
          Expression: 'HIGHEST{[last], WITHIN(5m)}',
          CalculatedColumnSettings: {
            DataType: 'number',
            Width: 100,
          },
        },
      ],
    },
    StyledColumn: {
      StyledColumns: [
        {
          Name: 'trend Icon',
          ColumnId: 'trend',
          IconStyle: {
            Preset: 'Trend',
            ToolTipText: ['CellValue'],
            Size: 18,
            Gap: 4,
          },
        },
        {
          Name: 'changePct gradient',
          ColumnId: 'changePct',
          GradientStyle: {
            ZeroCentred: {
              NegativeColor: '#ff5252',
              PositiveColor: 'var(--ab-color-success)',
            },
          },
        },
        {
          ColumnId: 'sessionTicks',
          Name: 'sessionTicks Sparkline',
          SparklineStyle: {
            options: {
              type: 'line',
              stroke: 'rgb(100, 220, 140)',
              strokeWidth: 2.5,
              padding: {top: 6, bottom: 6},
              marker: {
                enabled: true,
                size: 3,
                shape: 'circle',
                fill: 'rgb(100, 220, 140)',
                stroke: 'rgb(180, 255, 200)',
              },
            },
          },
        },
      ],
    },
    FormatColumn: {
      FormatColumns: [
        {
          Name: 'price-dollar',
          Scope: {ColumnIds: ['open', 'last', 'high5m']},
          DisplayFormat: 'Dollar',
        },
        {
          Name: 'format-pct',
          Scope: {ColumnIds: ['changePct']},
          DisplayFormat: {
            Formatter: 'NumberFormatter',
            Options: {Suffix: '%', FractionDigits: 2},
          },
        },
        {
          Name: 'format-volume',
          Scope: {ColumnIds: ['volume']},
          DisplayFormat: {
            Formatter: 'NumberFormatter',
            Options: {FractionDigits: 0},
          },
        },
      ],
    },
    Alert: {
      AlertDefinitions: [
        {
          Name: 'Choppy symbol',
          Type: 'Observable',
          MessageType: 'Info',
          MessageHeader: 'Choppy quote',
          MessageText: 'Last changed 3+ times in 45 seconds on this row',
          Scope: {All: true},
          Rule: 'CHANGE_COUNT{[last], WITHIN(45s)} >= 3',
          AlertProperties: {
            DisplayNotification: true,
            DisplaySystemStatusMessage: true,
          },
        },
        {
          Name: 'Session high',
          Type: 'Observable',
          MessageType: 'Success',
          MessageHeader: 'New high',
          MessageText: 'Last is the highest seen on this row in the last 5 minutes',
          Scope: {All: true},
          Rule: '[last] = HIGHEST{[last], WITHIN(5m)}',
          AlertProperties: {
            DisplayNotification: true,
          },
        },
        {
          Name: 'Stale row quote',
          Type: 'Observable',
          MessageType: 'Warning',
          MessageHeader: 'Stale quote',
          MessageText: 'No Last update on this row for 20 seconds',
          Scope: {All: true},
          Rule: 'NO_CHANGES{[last], WITHIN(20s)}',
          AlertProperties: {
            DisplayNotification: true,
            DisplaySystemStatusMessage: true,
          },
        },
        {
          Name: 'Feed paused',
          Type: 'Observable',
          MessageType: 'Error',
          MessageHeader: 'Feed stalled',
          MessageText: 'No Last updates anywhere in the grid for 12 seconds',
          Scope: {All: true},
          Rule: 'NO_CHANGES{[last], WITHIN(12s), ALL_ROWS()}',
          AlertProperties: {
            DisplayNotification: true,
            DisplaySystemStatusMessage: true,
          },
        },
        {
          Name: 'Busy market',
          Type: 'Observable',
          MessageType: 'Info',
          MessageHeader: 'Active market',
          MessageText: 'Last updated 8+ times grid-wide in 30 seconds',
          Scope: {All: true},
          Rule: 'CHANGE_COUNT{[last], WITHIN(30s), ALL_ROWS()} >= 8',
          AlertProperties: {
            DisplayNotification: true,
          },
        },
      ],
    },
  },
};
```

**`src/onAdaptableReady.ts`**

```ts
import {AdaptableReadyInfo} from '@adaptabletools/adaptable';
import {initLivePriceFeed} from './livePriceFeed';

export const onAdaptableReady = (readyInfo: AdaptableReadyInfo) => {
  initLivePriceFeed(readyInfo.adaptableApi);
};
```

**`src/livePriceFeed.ts`**

```ts
import {AdaptableApi} from '@adaptabletools/adaptable';
import {LiveQuote, MAX_SESSION_TICKS} from './rowData';

let apiRef: AdaptableApi | null = null;
let feedActive = false;
let intervalId: ReturnType<typeof setInterval> | null = null;

const TICK_MS = 1000;

/** Observable alerts that only make sense while the feed is expected to be live. */
const FEED_LIVE_ALERT_NAMES = ['Feed paused', 'Stale row quote'];

function setFeedLiveAlertsSuspended(suspended: boolean): void {
  if (!apiRef) {
    return;
  }

  FEED_LIVE_ALERT_NAMES.forEach((alertName) => {
    const alertDefinition = apiRef!.alertApi.getAlertDefinitionByName(alertName);
    if (!alertDefinition) {
      return;
    }
    if (suspended) {
      apiRef!.alertApi.suspendAlertDefinition(alertDefinition);
    } else {
      apiRef!.alertApi.unSuspendAlertDefinition(alertDefinition);
    }
  });
}

function randomInt(min: number, max: number): number {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

function tickOnce(): void {
  if (!apiRef || !feedActive) {
    return;
  }

  const nodes = apiRef.gridApi.getVisibleRowNodes();
  if (!nodes.length) {
    return;
  }

  const node = nodes[randomInt(0, nodes.length - 1)];
  const row = node.data as LiveQuote | undefined;
  if (!row) {
    return;
  }

  const deltaPct = randomInt(-12, 12) / 1000;
  const last = Math.max(0.01, Math.round(row.last * (1 + deltaPct) * 100) / 100);
  const changePct =
    Math.round(((last - row.open) / row.open) * 10000) / 100;
  const volume = row.volume + randomInt(500, 40_000);

  const sessionTicks = [...(row.sessionTicks ?? []), last].slice(-MAX_SESSION_TICKS);

  apiRef.gridApi.updateGridData([
    {
      ...row,
      last,
      changePct,
      sessionTicks,
      volume,
    },
  ]);
}

export function initLivePriceFeed(api: AdaptableApi): void {
  apiRef = api;
  stopLiveFeed();
}

export function startLiveFeed(): void {
  if (!apiRef) {
    return;
  }

  feedActive = true;
  if (intervalId) {
    clearInterval(intervalId);
  }

  intervalId = setInterval(tickOnce, TICK_MS);
  setFeedLiveAlertsSuspended(false);
  apiRef.systemStatusApi.setSuccessSystemStatus(
    'Price feed live',
    'Last and volume updating ~every second'
  );
}

export function stopLiveFeed(): void {
  feedActive = false;
  if (intervalId) {
    clearInterval(intervalId);
    intervalId = null;
  }

  if (apiRef) {
    setFeedLiveAlertsSuspended(true);
    apiRef.systemStatusApi.setWarningSystemStatus(
      'Price feed paused',
      'Click Start feed to resume ticking'
    );
  }
}

export function isLiveFeedActive(): boolean {
  return feedActive;
}
```

**`src/rowData.ts`**

```ts
export interface LiveQuote {
  id: number;
  symbol: string;
  name: string;
  sector: string;
  open: number;
  last: number;
  changePct: number;
  /** Recent Last prints for the session — `numberArray` sparkline column */
  sessionTicks: number[];
  volume: number;
}

const SESSION_TICK_COUNT = 14;

function buildSessionTicks(open: number, last: number, id: number): number[] {
  const ticks: number[] = [];
  for (let i = 0; i < SESSION_TICK_COUNT; i++) {
    const progress = i / (SESSION_TICK_COUNT - 1);
    const noise = (pseudoRandom(id * 41 + i) - 0.5) * 0.006 * open;
    ticks.push(
      Math.round((open + (last - open) * progress + noise) * 100) / 100
    );
  }
  ticks[ticks.length - 1] = last;
  return ticks;
}

const QUOTES = [
  {symbol: 'AAPL', name: 'Apple Inc', sector: 'Technology', open: 189.2},
  {symbol: 'MSFT', name: 'Microsoft Corp', sector: 'Technology', open: 415.5},
  {symbol: 'NVDA', name: 'NVIDIA Corp', sector: 'Technology', open: 875.0},
  {symbol: 'GOOGL', name: 'Alphabet Inc', sector: 'Technology', open: 172.4},
  {symbol: 'META', name: 'Meta Platforms', sector: 'Technology', open: 505.1},
  {symbol: 'AMZN', name: 'Amazon.com Inc', sector: 'Consumer', open: 178.6},
  {symbol: 'TSLA', name: 'Tesla Inc', sector: 'Consumer', open: 248.3},
  {symbol: 'JPM', name: 'JPMorgan Chase', sector: 'Financials', open: 198.7},
  {symbol: 'XOM', name: 'Exxon Mobil', sector: 'Energy', open: 118.2},
  {symbol: 'UNH', name: 'UnitedHealth', sector: 'Healthcare', open: 528.0},
  {symbol: 'SAP', name: 'SAP SE', sector: 'Technology', open: 198.5},
  {symbol: 'ASML', name: 'ASML Holding', sector: 'Technology', open: 945.0},
  {symbol: 'BP', name: 'BP plc', sector: 'Energy', open: 5.42},
  {symbol: 'TSM', name: 'Taiwan Semi', sector: 'Technology', open: 142.8},
] as const;

function pseudoRandom(seed: number): number {
  const x = Math.sin(seed * 4321) * 10000;
  return x - Math.floor(x);
}

export const rowData: LiveQuote[] = QUOTES.map((item, index) => {
  const id = index + 1;
  const drift = (pseudoRandom(id * 7) - 0.5) * 0.012;
  const last = Math.round(item.open * (1 + drift) * 100) / 100;
  const changePct = Math.round(((last - item.open) / item.open) * 10000) / 100;
  const volume = Math.round(500_000 + pseudoRandom(id * 11) * 8_000_000);

  return {
    id,
    symbol: item.symbol,
    name: item.name,
    sector: item.sector,
    open: item.open,
    last,
    changePct,
    sessionTicks: buildSessionTicks(item.open, last, id),
    volume,
  };
});

export const MAX_SESSION_TICKS = 20;
```

**`src/columnDefs.ts`**

```ts
import {ColDef} from 'ag-grid-enterprise';
import {LiveQuote} from './rowData';

export const columnDefs: ColDef<LiveQuote>[] = [
  {field: 'id', hide: true, cellDataType: 'number'},
  {field: 'symbol', headerName: 'Symbol', cellDataType: 'text', width: 96},
  {
    field: 'name',
    headerName: 'Name',
    cellDataType: 'text',
    flex: 1,
    minWidth: 140,
  },
  {field: 'sector', cellDataType: 'text', width: 120},
  {
    field: 'open',
    headerName: 'Open',
    cellDataType: 'number',
    type: 'rightAligned',
    width: 100,
  },
  {
    field: 'last',
    headerName: 'Last',
    cellDataType: 'number',
    type: 'rightAligned',
    width: 100,
  },
  {
    field: 'changePct',
    headerName: 'Chg %',
    cellDataType: 'number',
    type: 'rightAligned',
    width: 88,
  },
  {
    field: 'sessionTicks',
    headerName: 'Tick trend',
    cellDataType: 'numberArray',
    sortable: false,
    filter: false,
    width: 168,
  },
  {
    field: 'volume',
    headerName: 'Volume',
    cellDataType: 'number',
    type: 'rightAligned',
    width: 110,
  },
];
```

**`src/gridOptions.ts`**

```ts
import {GridOptions, themeBalham} from 'ag-grid-enterprise';

import {columnDefs} from './columnDefs';
import {rowData, LiveQuote} from './rowData';

export const gridOptions: GridOptions<LiveQuote> = {
  theme: themeBalham,
  defaultColDef: {
    resizable: true,
    sortable: true,
    editable: false,
    filter: true,
    floatingFilter: true,
  },
  columnDefs: columnDefs,
  rowData: rowData,
  enableCharts: true,
  sideBar: true,
  suppressMenuHide: true,
  columnMenu: 'new',
  cellSelection: true,
  statusBar: {
    statusPanels: [
      {statusPanel: 'agTotalRowCountComponent', align: 'left'},
      {statusPanel: 'agFilteredRowCountComponent'},
      {
        key: 'Center Panel',
        statusPanel: 'AdaptableStatusPanel',
        align: 'center',
      },
    ],
  },
};
```
