# Equity Watchlist

Canonical page: https://www.adaptabletools.com/docs/showcase-demos-equity-watchlist

**Example: Showcase: Equity Watchlist**

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]
  onAdaptableReady.ts  [template]
  rowData.ts
  styles.css           [template + shown below]
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 {
  ActionColumnContext,
  AdaptableButton,
  AdaptableOptions,
  CellUpdateRequest,
  CustomToolbarFormContext,
} from '@adaptabletools/adaptable';
import {WatchlistEntry} from './rowData';

function applyWatchlistToolbarFilter(context: CustomToolbarFormContext) {
  const sector = context.formData.sector as string | undefined;
  const moversOnly = Boolean(context.formData.moversOnly);

  const expressions: string[] = [];
  if (sector && sector !== 'All') {
    expressions.push(`[sector] = "${sector}"`);
  }
  if (moversOnly) {
    expressions.push('QUERY("Big Movers")');
  }

  context.adaptableApi.filterApi.gridFilterApi.setGridFilterExpression(
    expressions.length ? expressions.join(' AND ') : null
  );
}

export const adaptableOptions: AdaptableOptions<WatchlistEntry> = {
  primaryKey: 'id',
  adaptableId: 'Showcase: Equity Watchlist',
  userName: 'Research Analyst',
  dashboardOptions: {
    customToolbars: [
      {
        name: 'WatchlistToolbar',
        title: 'Watchlist',
        toolbarForm: {
          fields: [
            {
              name: 'sector',
              label: 'Sector',
              fieldType: 'select',
              defaultValue: 'All',
              options: [
                {label: 'All sectors', value: 'All'},
                {label: 'Technology', value: 'Technology'},
                {label: 'Financials', value: 'Financials'},
                {label: 'Healthcare', value: 'Healthcare'},
                {label: 'Energy', value: 'Energy'},
                {label: 'Consumer', value: 'Consumer'},
                {label: 'Industrials', value: 'Industrials'},
              ],
              onValueChange: (_value, ctx) =>
                applyWatchlistToolbarFilter(ctx as CustomToolbarFormContext),
            },
            {
              name: 'moversOnly',
              label: 'Big movers only',
              fieldType: 'checkbox',
              defaultValue: false,
              onValueChange: (_value, ctx) =>
                applyWatchlistToolbarFilter(ctx as CustomToolbarFormContext),
            },
          ],
          buttons: [
            {
              label: 'Clear',
              buttonStyle: {
                tone: 'neutral',
                variant: 'raised',
                className: 'watchlist-clear-btn',
              },
              onClick: (
                _button: AdaptableButton<CustomToolbarFormContext>,
                context: CustomToolbarFormContext
              ) => {
                context.adaptableApi.filterApi.gridFilterApi.clearGridFilter();
                context.adaptableApi.dashboardApi.resetCustomToolbarFormData(
                  'WatchlistToolbar'
                );
              },
            },
          ],
        },
      },
    ],
  },
  actionColumnOptions: {
    actionColumns: [
      {
        columnId: 'quick_buy',
        friendlyName: 'Idea',
        controls: [
          {
            type: 'button',
            label: 'Buy',
            style: {variant: 'outlined', tone: 'success'},
            disabled: (context: ActionColumnContext) =>
              context.data?.recommendation === 'Buy',
            onClick: (context: ActionColumnContext) => {
              const cellUpdateRequest: CellUpdateRequest = {
                columnId: 'recommendation',
                newValue: 'Buy',
                primaryKeyValue: context.primaryKeyValue,
                rowNode: context.rowNode,
              };
              context.adaptableApi.gridApi.setCellValue(cellUpdateRequest);
            },
          },
        ],
      },
    ],
  },
  initialState: {
    Dashboard: {
      Tabs: [
        {
          Name: 'Watch',
          Toolbars: ['WatchlistToolbar', 'Layout', 'Alert', 'GridFilter'],
        },
        {
          Name: 'Charts',
          Toolbars: ['Charting', 'Layout'],
        },
      ],
      ModuleButtons: [
        'Layout',
        'StyledColumn',
        'CalculatedColumn',
        'Alert',
        'Note',
      ],
    },
    StatusBar: {
      StatusBars: [
        {
          Key: 'Center Panel',
          StatusBarPanels: ['Layout', 'Alert'],
        },
      ],
    },
    Theme: {CurrentTheme: 'dark'},
    NamedQuery: {
      NamedQueries: [
        {
          Name: 'Big Movers',
          Expression: 'ABS([changePct]) > 2',
        },
      ],
    },
    Layout: {
      CurrentLayout: 'Compact',
      Layouts: [
        {
          Name: 'Compact',
          AutoSizeColumns: true,
          TableColumns: [
            'listingCountry',
            'symbol',
            'last',
            'changePct',
            'priceTrend',
            'recommendation',
            'weightPct',
            'quick_buy',
          ],
          ColumnSizing: {
            listingCountry: {Width: 64},
            symbol: {Width: 92},
            last: {Width: 88},
            changePct: {Width: 82},
            priceTrend: {Width: 168, MinWidth: 160},
            recommendation: {Width: 72},
            weightPct: {Width: 110, MinWidth: 110},
          },
        },
        {
          Name: 'Research',
          AutoSizeColumns: true,
          TableColumns: [
            'listingCountry',
            'symbol',
            'name',
            'sector',
            'last',
            'changePct',
            'priceTrend',
            'targetPrice',
            'upside_pct',
            'recommendation',
            'analystScore',
            'position',
            'marketValue',
            'quick_buy',
          ],
          ColumnSizing: {
            listingCountry: {Width: 64},
            symbol: {Width: 88},
            name: {Width: 132, MinWidth: 120},
            sector: {Width: 104, MinWidth: 96},
            last: {Width: 80},
            changePct: {Width: 76},
            priceTrend: {Width: 148, MinWidth: 140},
            targetPrice: {Width: 84},
            upside_pct: {Width: 84},
            recommendation: {Width: 68},
            analystScore: {Width: 100, MinWidth: 96},
            position: {Width: 64},
            marketValue: {Width: 96},
            quick_buy: {Width: 72},
          },
        },
        {
          Name: 'Movers',
          AutoSizeColumns: true,
          TableColumns: [
            'symbol',
            'name',
            'sector',
            'last',
            'changePct',
            'priceTrend',
            'recommendation',
            'analystScore',
            'quick_buy',
          ],
          GridFilter: {
            Expression: 'QUERY("Big Movers")',
          },
          ColumnSizing: {
            sector: {Width: 104, MinWidth: 96},
            last: {Width: 80},
            changePct: {Width: 76},
            priceTrend: {Width: 148, MinWidth: 140},
            recommendation: {Width: 64, MinWidth: 60},
            analystScore: {Width: 100, MinWidth: 96},
            quick_buy: {Width: 72},
          },
        },
      ],
    },
    CalculatedColumn: {
      CalculatedColumns: [
        {
          FriendlyName: 'Upside %',
          ColumnId: 'upside_pct',
          Expression: '(([targetPrice] - [last]) / [last]) * 100',
          CalculatedColumnSettings: {
            DataType: 'number',
            Filterable: true,
          },
        },
      ],
    },
    StyledColumn: {
      StyledColumns: [
        {
          Name: 'Listing country flag',
          ColumnId: 'listingCountry',
          IconStyle: {
            Preset: 'Flags',
            Mappings: [{Key: 'TW', Icon: '🇹🇼', Description: 'Taiwan'}],
            MatchMode: 'CaseInsensitive',
            ToolTipText: ['IconDescription', 'CellValue'],
            Size: 18,
            Gap: 4,
            FallbackProperties: { Mode: 'ShowText' },
          },
        },
        {
          Name: '30d trend',
          ColumnId: 'priceTrend',
          SparklineStyle: {
            options: {
              type: 'line',
              stroke: 'rgb(100, 181, 246)',
              strokeWidth: 2,
              padding: {top: 4, bottom: 4},
            },
          },
        },
        {
          Name: 'Sector badge',
          ColumnId: 'sector',
          BadgeStyle: {
            Badges: [
              {
                Rule: '[sector] = "Technology"',
                PillStyle: {
                  BackColor: 'var(--ab-color-palette-7)',
                  ForeColor: 'var(--ab-color-palette-8)',
                },
              },
              {
                Rule: '[sector] = "Financials"',
                PillStyle: {
                  BackColor: 'var(--ab-color-palette-11)',
                  ForeColor: 'var(--ab-color-palette-12)',
                },
              },
              {
                Rule: '[sector] = "Healthcare"',
                PillStyle: {
                  BackColor: 'var(--ab-color-palette-5)',
                  ForeColor: 'var(--ab-color-palette-6)',
                },
              },
              {
                Rule: '[sector] = "Energy"',
                PillStyle: {
                  BackColor: 'var(--ab-color-palette-1)',
                  ForeColor: 'var(--ab-color-palette-2)',
                },
              },
              {
                Rule: '[sector] = "Consumer"',
                PillStyle: {
                  BackColor: 'var(--ab-color-palette-3)',
                  ForeColor: 'var(--ab-color-palette-4)',
                },
              },
              {
                Rule: '[sector] = "Industrials"',
                PillStyle: {
                  BackColor: 'var(--ab-color-palette-9)',
                  ForeColor: 'var(--ab-color-palette-10)',
                },
              },
            ],
          },
        },
        {
          Name: 'Recommendation',
          ColumnId: 'recommendation',
          BadgeStyle: {
            Badges: [
              {
                Rule: '[recommendation] = "Buy"',
                PillStyle: {
                  BackColor: 'var(--ab-color-palette-5)',
                  ForeColor: 'var(--ab-color-palette-6)',
                },
              },
              {
                Rule: '[recommendation] = "Hold"',
                PillStyle: {
                  BackColor: 'var(--ab-color-palette-3)',
                  ForeColor: 'var(--ab-color-palette-4)',
                },
              },
              {
                Rule: '[recommendation] = "Sell"',
                PillStyle: {
                  BackColor: 'var(--ab-color-palette-1)',
                  ForeColor: 'var(--ab-color-palette-2)',
                },
              },
            ],
          },
        },
        {
          Name: 'analystScore Rating',
          ColumnId: 'analystScore',
          RatingStyle: {
            Icon: 'Star',
            Max: 5,
            Size: 14,
            Gap: 2,
            AllowHalf: true,
            ShowValue: false,
            FilledColor: 'var(--ab-color-warn)',
            EmptyColor: 'color-mix(in srgb, currentColor 22%, transparent)',
          },
        },
        {
          Name: 'weightPct PercentBar',
          ColumnId: 'weightPct',
          PercentBarStyle: {
            RangeValueType: 'Number',
            CellRanges: [
              {Min: 0, Max: 4, Color: 'var(--ab-color-palette-10)'},
              {Min: 4, Max: 8, Color: 'var(--ab-color-info)'},
              {Min: 8, Max: 15, Color: 'var(--ab-color-warn)'},
            ],
            BackColor: 'var(--ab-color-palette-9)',
          },
        },
        {
          Name: 'changePct Gradient',
          ColumnId: 'changePct',
          GradientStyle: {
            ZeroCentred: {
              NegativeColor: 'var(--ab-color-destructive)',
              PositiveColor: 'var(--ab-color-success)',
            },
          },
        },
      ],
    },
    FormatColumn: {
      FormatColumns: [
        {
          Name: 'last Dollar',
          Scope: {ColumnIds: ['last', 'targetPrice']},
          DisplayFormat: 'Dollar',
        },
        {
          Name: 'marketValue Million',
          Scope: {ColumnIds: ['marketValue']},
          DisplayFormat: 'Million',
        },
        {
          Name: 'format-pct',
          Scope: {ColumnIds: ['changePct', 'upside_pct', 'weightPct']},
          DisplayFormat: {
            Formatter: 'NumberFormatter',
            Options: {Suffix: '%', FractionDigits: 2},
          },
        },
      ],
    },
    FlashingCell: {
      FlashingCellDefinitions: [
        {
          Name: 'Upside % reacts to target',
          Scope: {ColumnIds: ['upside_pct']},
          Rule: 'ANY_CHANGE()',
        },
      ],
    },
    Alert: {
      AlertDefinitions: [
        {
          Name: 'Upside revised',
          MessageType: 'Info',
          MessageHeader: 'Upside % updated',
          MessageText:
            'The target change revised upside on this row — check the Research layout.',
          Scope: {ColumnIds: ['upside_pct']},
          Rule: 'ANY_CHANGE()',
          AlertProperties: {
            DisplayNotification: true,
          },
        },
        {
          Name: 'Big Movers',
          MessageType: 'Warning',
          MessageHeader: 'Big mover on watchlist',
          MessageText:
            'Daily change exceeds ±2% (|Chg %| > 2) — review the name before the open.',
          Scope: {All: true},
          Rule: 'QUERY("Big Movers")',
          AlertProperties: {
            DisplayNotification: true,
          },
        },
      ],
    },
    Note: {
      Notes: [
        {
          PrimaryKeyValue: 5,
          ColumnId: 'symbol',
          Text: 'Raising estimates after data-centre demand print.',
          Author: 'Research Analyst',
          NoteType: 'Success',
          CreatedAt: Date.now() - 86_400_000,
          UpdatedAt: Date.now() - 86_400_000,
        },
        {
          PrimaryKeyValue: 15,
          ColumnId: 'name',
          Text: 'Watch delivery numbers — high beta into earnings.',
          Author: 'Research Analyst',
          NoteType: 'Warning',
          CreatedAt: Date.now() - 172_800_000,
          UpdatedAt: Date.now() - 172_800_000,
        },
        {
          PrimaryKeyValue: 19,
          ColumnId: 'symbol',
          Text: 'Pipeline read-through weaker than peers; hold for now.',
          Author: 'Research Analyst',
          NoteType: 'Error',
          CreatedAt: Date.now() - 259_200_000,
          UpdatedAt: Date.now() - 259_200_000,
        },
      ],
    },
    Charting: {
      ChartDefinitions: [
        {
          Name: 'Movers by change %',
          Model: {
            modelType: 'range',
            chartId: 'watchlist-movers-bar',
            chartType: 'bar',
            chartThemeName: 'ag-vivid-dark',
            chartOptions: {
              bar: {
                title: {text: 'Top movers (sample)'},
              },
            },
            cellRange: {
              rowStartIndex: 0,
              rowEndIndex: 11,
              columns: ['symbol', 'changePct'],
            },
            suppressChartRanges: false,
            unlinkChart: false,
          },
        },
      ],
    },
  },
};
```

**`src/rowData.ts`**

```ts
export type Recommendation = 'Buy' | 'Hold' | 'Sell';

export interface WatchlistEntry {
  id: number;
  symbol: string;
  name: string;
  listingCountry: string;
  sector: string;
  last: number;
  changePct: number;
  volume: number;
  targetPrice: number;
  recommendation: Recommendation;
  analystScore: number;
  priceHistory: number[];
  position: number;
  marketValue: number;
  weightPct: number;
}

/** US names interleaved with international listings so flags are visible on first open. */
const WATCHLIST = [
  {symbol: 'AAPL', name: 'Apple Inc', listingCountry: 'US', sector: 'Technology', base: 189},
  {symbol: 'SAP', name: 'SAP SE', listingCountry: 'DE', sector: 'Technology', base: 198},
  {symbol: 'MSFT', name: 'Microsoft Corp', listingCountry: 'US', sector: 'Technology', base: 415},
  {symbol: 'ASML', name: 'ASML Holding', listingCountry: 'NL', sector: 'Technology', base: 945},
  {symbol: 'NVDA', name: 'NVIDIA Corp', listingCountry: 'US', sector: 'Technology', base: 875},
  {symbol: 'TSM', name: 'Taiwan Semiconductor', listingCountry: 'TW', sector: 'Technology', base: 142},
  {symbol: 'GOOGL', name: 'Alphabet Inc', listingCountry: 'US', sector: 'Technology', base: 172},
  {symbol: 'NESN', name: 'Nestlé SA', listingCountry: 'CH', sector: 'Consumer', base: 98},
  {symbol: 'META', name: 'Meta Platforms', listingCountry: 'US', sector: 'Technology', base: 505},
  {symbol: 'HSBA', name: 'HSBC Holdings', listingCountry: 'GB', sector: 'Financials', base: 7.2},
  {symbol: 'AMZN', name: 'Amazon.com Inc', listingCountry: 'US', sector: 'Consumer', base: 178},
  {symbol: 'BABA', name: 'Alibaba Group', listingCountry: 'CN', sector: 'Consumer', base: 78},
  {symbol: 'JPM', name: 'JPMorgan Chase', listingCountry: 'US', sector: 'Financials', base: 198},
  {symbol: 'BP', name: 'BP plc', listingCountry: 'GB', sector: 'Energy', base: 5.4},
  {symbol: 'TSLA', name: 'Tesla Inc', listingCountry: 'US', sector: 'Consumer', base: 248},
  {symbol: 'BAC', name: 'Bank of America', listingCountry: 'US', sector: 'Financials', base: 38},
  {symbol: 'GS', name: 'Goldman Sachs', listingCountry: 'US', sector: 'Financials', base: 462},
  {symbol: 'UNH', name: 'UnitedHealth Group', listingCountry: 'US', sector: 'Healthcare', base: 528},
  {symbol: 'JNJ', name: 'Johnson & Johnson', listingCountry: 'US', sector: 'Healthcare', base: 156},
  {symbol: 'PFE', name: 'Pfizer Inc', listingCountry: 'US', sector: 'Healthcare', base: 28},
  {symbol: 'XOM', name: 'Exxon Mobil', listingCountry: 'US', sector: 'Energy', base: 118},
  {symbol: 'CVX', name: 'Chevron Corp', listingCountry: 'US', sector: 'Energy', base: 162},
  {symbol: 'CAT', name: 'Caterpillar Inc', listingCountry: 'US', sector: 'Industrials', base: 348},
  {symbol: 'BA', name: 'Boeing Co', listingCountry: 'US', sector: 'Industrials', base: 188},
] as const;

const RECOMMENDATIONS: Recommendation[] = ['Buy', 'Hold', 'Sell'];

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

/** Curated 0–15% weights so percent-bar bands (incl. 0–4) are visible near the top. */
const DEMO_WEIGHT_PCTS = [
  2.4, 6.1, 11.4, 3.2, 5.5, 9.8, 1.2, 7.6, 12.3, 4.5, 6.8, 10.2, 2.9, 8.1,
  13.5, 3.8, 5.0, 9.0, 1.5, 7.0, 11.0, 4.0, 6.5, 10.8,
] as const;

function buildHistory(last: number, seed: number): number[] {
  const points: number[] = [];
  let price = last * (0.92 + pseudoRandom(seed) * 0.08);
  for (let i = 0; i < 15; i++) {
    const drift = (pseudoRandom(seed + i * 3) - 0.48) * 0.04;
    price = price * (1 + drift);
    points.push(Math.round(price * 100) / 100);
  }
  points[14] = last;
  return points;
}

export const rowData: WatchlistEntry[] = WATCHLIST.map((item, index) => {
  const id = index + 1;
  const changePct =
    Math.round((pseudoRandom(id * 11) - 0.42) * 600) / 100;
  const last =
    Math.round(item.base * (1 + changePct / 100) * 100) / 100;
  const targetPrice =
    Math.round(last * (1 + (pseudoRandom(id * 5) - 0.35) * 0.2) * 100) /
    100;
  const weightPct = DEMO_WEIGHT_PCTS[index];
  const position = Math.round((weightPct / 100) * 4_200_000 / last);
  const marketValue = Math.round(position * last);

  return {
    id,
    symbol: item.symbol,
    name: item.name,
    listingCountry: item.listingCountry,
    sector: item.sector,
    last,
    changePct,
    volume: Math.round(800_000 + pseudoRandom(id * 13) * 12_000_000),
    targetPrice,
    recommendation: RECOMMENDATIONS[Math.floor(pseudoRandom(id * 17) * 3)],
    analystScore: Math.max(
      1,
      Math.min(5, Math.round(1 + pseudoRandom(id * 19) * 4))
    ),
    priceHistory: buildHistory(last, id),
    position,
    marketValue,
    weightPct,
  };
});

function tuneRow(symbol: string, patch: Partial<WatchlistEntry>) {
  const row = rowData.find(entry => entry.symbol === symbol);
  if (row) {
    Object.assign(row, patch);
  }
}

// A few names tuned for the demo narrative.
tuneRow('NVDA', {changePct: 4.8, recommendation: 'Buy', analystScore: 5});
tuneRow('AMZN', {changePct: -3.6, recommendation: 'Sell', analystScore: 2});
tuneRow('JNJ', {changePct: 3.1, recommendation: 'Hold', analystScore: 4});
tuneRow('ASML', {changePct: -2.9, recommendation: 'Hold', analystScore: 3});
```

**`src/columnDefs.ts`**

```ts
import {ColDef} from 'ag-grid-enterprise';

export const columnDefs: ColDef[] = [
  {field: 'symbol', headerName: 'Symbol', cellDataType: 'text', width: 100},
  {
    field: 'listingCountry',
    headerName: 'Listing',
    cellDataType: 'text',
    width: 72,
  },
  {
    field: 'name',
    headerName: 'Name',
    cellDataType: 'text',
    flex: 1,
    minWidth: 160,
  },
  {
    field: 'sector',
    cellDataType: 'text',
    enableRowGroup: true,
    enablePivot: true,
  },
  {
    field: 'last',
    cellDataType: 'number',
    type: 'rightAligned',
  },
  {
    field: 'changePct',
    headerName: 'Chg %',
    cellDataType: 'number',
    type: 'rightAligned',
  },
  {
    field: 'priceHistory',
    colId: 'priceTrend',
    headerName: '30d',
    cellDataType: 'numberArray',
    sortable: false,
    filter: false,
    floatingFilter: false,
    width: 155,
    minWidth: 145,
  },
  {
    field: 'targetPrice',
    headerName: 'Target',
    cellDataType: 'number',
    type: 'rightAligned',
    editable: true,
  },
  {
    field: 'recommendation',
    headerName: 'Rec',
    cellDataType: 'text',
    editable: true,
  },
  {
    field: 'analystScore',
    headerName: 'Conviction',
    cellDataType: 'number',
    type: 'rightAligned',
  },
  {
    field: 'position',
    headerName: 'Pos',
    cellDataType: 'number',
    type: 'rightAligned',
  },
  {
    field: 'marketValue',
    headerName: 'Mkt Val',
    cellDataType: 'number',
    type: 'rightAligned',
  },
  {
    field: 'weightPct',
    headerName: 'Weight %',
    cellDataType: 'number',
    type: 'rightAligned',
    width: 96,
    minWidth: 90,
  },
  {field: 'volume', hide: true},
  {field: 'id', hide: true, editable: false},
];
```

**`src/gridOptions.ts`**

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

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

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

**`src/styles.css`**

```css
/*
 * Neutral raised buttons use --ab-color-primary as fill. In dark theme that
 * colour (#262d2f) sits very close to the dashboard background (#474c52), so
 * the button nearly disappears. Light theme primary (#e9e9e9) contrasts well.
 */
.ab--theme-dark
  .ab-CustomToolbar--WatchlistToolbar
  .watchlist-clear-btn.ab-SimpleButton--variant-raised {
  background-color: #5c646c !important;
  border: 1px solid #727b84;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
}
```
