Trading  ·  BraivIQ AI Engineering Playbook

Trade Surveillance AI In Code: Triage Alerts With An Analyst In The Loop And Chart The Evidence

Trade surveillance AI earns its place in alert triage, not in the final decision. It assembles the evidence for each alert, orders the queue by how often similar alerts were escalated in the past, and shows the analyst a chart of prices and the trader's orders, while a named analyst still decides every alert. On 22 September 2026 the FCA's chief executive said the regulator is exploring agentic AI as its "first responder" for wholesale market monitoring, and on 16 September TradingView published the first official plugin packages for Lightweight Charts. This playbook builds the triage pipeline in Python and the evidence chart in TypeScript, with sample data only.

Published  ·  Updated  ·  12 min read  ·  By BraivIQ Engineering

Trading charts on several screens and a tablet, illustrating trade surveillance AI for alert triage at a UK trading firm

Key takeaways

  • The FCA's chief executive said on 22 September 2026 that the regulator is "exploring agentic AI as our 'first responder'" to speed up wholesale market monitoring, working with about a billion rows of data a day.
  • In a firm's own surveillance, AI is most defensible as a triage layer: it builds the evidence pack and orders the queue, and a named analyst records every disposition.
  • Order the queue by escalation rates measured on your analysts' past decisions, not by a vendor score or a model's raw confidence, and send a sample of batch closures to second review.
  • TradingView's first official Lightweight Charts plugin packages, released on 16 September 2026, include a vertical-line plugin that marks the alert window on the evidence chart. The library itself stays at 5.2.1.
  • The Python sample was run on Python 3.14.4 and 3.11.14 and passes mypy --strict. The chart passes strict type-checking with TypeScript 7.0.2 and was rendered in Chrome from the Python sample's output.

22 Sep 2026 - FCA chief executive: the regulator is exploring agentic AI as a "first responder" for wholesale market monitoring  ·  1 billion - Rows of data a day the FCA says it works with on market abuse  ·  16 Sep 2026 - First official Lightweight Charts plugin packages, including a vertical-line plugin  ·  0 - Alerts closed by the AI in this design. Every one ends with a named analyst

Surveillance teams in UK trading firms do not lack alerts. They lack hours. Trade surveillance AI earns its place by doing the slow, repetitive part of the job: assembling the orders, cancels and fills behind each alert, putting the alerts most likely to need escalation at the top of the queue and drawing the evidence so an analyst can judge it in seconds. The analyst still decides every alert, and that is the line this playbook holds throughout.

It is engineering content for developers who build surveillance tooling at trading firms, brokers and banks. It gives no view on any trading strategy, and all market data in the samples is generated sample data.

What did the FCA say about AI in market surveillance in September 2026?

At a TheCityUK dinner on 22 September 2026, published on 23 September, FCA chief executive Nikhil Rathi said: "We are already exploring agentic AI as our 'first responder' to speed up how we monitor wholesale markets". He described the regulator using its "large data sets – a billion rows of data per day – alongside our supervisory judgement" to tackle market abuse faster. The FCA notes that the published text is the drafted speech and may differ from what was delivered.

The phrase that matters for firms is "alongside our supervisory judgement". The regulator is describing AI that speeds up the first look and people who make the call. That is the same split we recommend inside a firm, and it is the design in this playbook.

What does a trading workflow architecture for alert triage look like?

Your existing surveillance system keeps raising alerts. A read-only replica of order and trade data feeds an evidence builder. A prioritiser, either transparent points or a decision model, scores each alert and is calibrated on past dispositions. Analysts work the queue in that order with an evidence chart beside each alert. A sample of quick closures goes to a second analyst, escalations go to compliance, and every decision is written to an audit log with a hash of the evidence the analyst saw.

Trade surveillance AI architecture diagram: the surveillance system raises alerts and a read-only replica of orders and trades feeds an evidence pack, a prioritiser calibrated on past dispositions orders the analyst queue, an evidence chart shows prices, orders and the alert window, analysts make named dispositions, a sample of batch closures goes to second review, escalations go to compliance and everything is written to the audit log.
The prioritiser orders the work. People decide every alert. Tap to open full size.

This trading workflow architecture deliberately leaves detection to the system you already run. Our earlier playbook on AI trade surveillance and explainable alerts covers detection. Triage is a separate problem with a separate risk profile, and keeping it separate means you can change the prioritiser without touching the scenarios your compliance team has signed off.

How do you build the evidence pack and order the analyst queue in Python?

The first sample is standard-library Python. We ran it on Python 3.14.4 and 3.11.14 and it passes mypy --strict. It builds evidence for each alert, converts it to a transparent priority bucket, turns each bucket into an escalation rate measured on past dispositions, orders the queue and records a named analyst's decision with a hash of the evidence.

alert_triage.py
"""Surveillance alert triage: build the evidence, order the analyst queue, keep every disposition with a person.

Python 3.11+, standard library only. Orders, prices and alert history are sample data.
The prioritiser decides the order in which analysts see alerts. It never closes one.
"""
from __future__ import annotations

import hashlib
import json
import random
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from statistics import median
from typing import Literal


@dataclass(frozen=True)
class Alert:
    alert_id: str
    scenario: str  # raised by your existing surveillance system
    trader_id: str
    isin: str
    start: datetime
    end: datetime


@dataclass(frozen=True)
class Order:
    order_id: str
    trader_id: str
    isin: str
    side: Literal["buy", "sell"]
    price: float
    qty: int
    placed: datetime
    cancelled: datetime | None
    filled_qty: int


@dataclass(frozen=True)
class Evidence:
    alert_id: str
    orders: int
    cancel_ratio: float
    median_life_s: float
    opposite_side_filled: int
    prior_escalations: int


def build_evidence(alert: Alert, orders: list[Order], escalations_by_trader: dict[str, int]) -> Evidence:
    window = [o for o in orders if o.trader_id == alert.trader_id and o.isin == alert.isin
              and alert.start <= o.placed <= alert.end]
    cancelled = [o for o in window if o.cancelled is not None]
    lives = [(o.cancelled - o.placed).total_seconds() for o in cancelled if o.cancelled]
    sell_heavy = sum(o.qty for o in window if o.side == "sell") >= sum(o.qty for o in window if o.side == "buy")
    opposite = sum(o.filled_qty for o in window if o.side == ("buy" if sell_heavy else "sell"))
    return Evidence(alert.alert_id, len(window), round(len(cancelled) / max(len(window), 1), 3),
                    round(median(lives), 2) if lives else 0.0, opposite, escalations_by_trader.get(alert.trader_id, 0))


def bucket(e: Evidence) -> int:
    """Transparent points, so an analyst can see why an alert sits where it does. 0 (low) to 3 (high)."""
    points = (2 if e.cancel_ratio >= 0.8 else 0) + (2 if e.median_life_s < 3 else 0) \
        + (2 if e.opposite_side_filled > 0 else 0) + (1 if e.prior_escalations > 0 else 0)
    return min(points // 2, 3)


def calibrate(history: list[tuple[int, bool]]) -> dict[int, float]:
    """Escalation rate per bucket from analysts' past dispositions, with add-one smoothing."""
    return {b: (sum(esc for bb, esc in history if bb == b) + 1) / (sum(1 for bb, _ in history if bb == b) + 2)
            for b in range(4)}


@dataclass(frozen=True)
class QueueItem:
    alert_id: str
    bucket: int
    p_escalation: float
    review: Literal["full review", "batch review"]
    evidence: Evidence


def build_queue(alerts: list[Alert], orders: list[Order], escalations: dict[str, int],
                rates: dict[int, float], full_review_from: float = 0.15) -> list[QueueItem]:
    items = []
    for a in alerts:
        e = build_evidence(a, orders, escalations)
        b = bucket(e)
        items.append(QueueItem(a.alert_id, b, rates[b], "full review" if rates[b] >= full_review_from else "batch review", e))
    return sorted(items, key=lambda i: i.p_escalation, reverse=True)


def record_disposition(item: QueueItem, analyst_id: str, decision: Literal["close", "escalate"], reason: str,
                       log: list[dict[str, object]], qa_rate: float = 0.10) -> dict[str, object]:
    """Every alert ends with a named analyst's decision. A share of batch closures goes to second review."""
    evidence_digest = hashlib.sha256(json.dumps(asdict(item.evidence), sort_keys=True).encode()).hexdigest()
    qa = decision == "close" and item.review == "batch review" and \
        int(hashlib.sha256(item.alert_id.encode()).hexdigest(), 16) % 100 < qa_rate * 100
    entry: dict[str, object] = {"ts": datetime.now(timezone.utc).isoformat(), "alert_id": item.alert_id, "analyst": analyst_id,
                                "decision": decision, "reason": reason, "bucket": item.bucket,
                                "p_escalation": round(item.p_escalation, 3), "evidence_sha256": evidence_digest,
                                "second_review": qa}
    log.append(entry)
    return entry


def chart_payload(alert: Alert, orders: list[Order], seed: int = 3) -> dict[str, object]:
    """Evidence for the analyst's chart: one-minute candles (sample prices) and this trader's orders."""
    rng, price, candles = random.Random(seed), 101.10, []
    for m in range(-10, 21):
        t = alert.start + timedelta(minutes=m)
        o = price
        price = round(price + rng.uniform(-0.05, 0.05), 2)
        candles.append({"time": int(t.timestamp()), "open": o, "high": round(max(o, price) + 0.02, 2),
                        "low": round(min(o, price) - 0.02, 2), "close": price})
    mine = [o for o in orders if o.trader_id == alert.trader_id and o.isin == alert.isin]
    return {"alert": {"id": alert.alert_id, "scenario": alert.scenario, "start": int(alert.start.timestamp()),
                      "end": int(alert.end.timestamp())},
            "candles": candles,
            "orders": [{"id": o.order_id, "side": o.side, "price": o.price, "qty": o.qty,
                        "time": int(o.placed.timestamp()) // 60 * 60, "cancelled": o.cancelled is not None,
                        "filled": o.filled_qty} for o in mine]}


def sample_data(seed: int = 11) -> tuple[list[Alert], list[Order], dict[str, int], list[tuple[int, bool]]]:
    rng = random.Random(seed)
    t0 = datetime(2026, 10, 8, 14, 30, tzinfo=timezone.utc)
    orders: list[Order] = []
    for n in range(14):  # a cluster of sell orders cancelled within seconds, then a buy fill
        placed = t0 + timedelta(minutes=2, seconds=20 * n)
        orders.append(Order(f"S{n}", "TR-07", "GB00SAMPLE002", "sell", round(101.20 + 0.02 * n, 2), 5000, placed,
                            placed + timedelta(seconds=rng.uniform(0.4, 2.2)), 0))
    orders.append(Order("B1", "TR-07", "GB00SAMPLE002", "buy", 101.05, 8000, t0 + timedelta(minutes=6), None, 8000))
    for n in range(6):  # ordinary activity from another trader
        placed = t0 + timedelta(minutes=n * 3)
        orders.append(Order(f"N{n}", "TR-19", "GB00SAMPLE002", "buy", 101.0, 1000, placed, None, 1000))
    alerts = [Alert("A-1001", "layering", "TR-07", "GB00SAMPLE002", t0, t0 + timedelta(minutes=10)),
              Alert("A-1002", "layering", "TR-19", "GB00SAMPLE002", t0, t0 + timedelta(minutes=20))]
    history = [(rng.choice([0, 0, 0, 1, 1, 2, 3]), False) for _ in range(400)]
    history += [(3, True)] * 30 + [(2, True)] * 12 + [(1, True)] * 4 + [(0, True)] * 1
    return alerts, orders, {"TR-07": 1}, history


if __name__ == "__main__":
    alerts, orders, escalations, history = sample_data()
    rates = calibrate(history)
    print({b: round(r, 3) for b, r in rates.items()})
    queue = build_queue(alerts, orders, escalations, rates)
    for q in queue:
        print(q.alert_id, q.bucket, f"{q.p_escalation:.1%}", q.review, asdict(q.evidence))
    log: list[dict[str, object]] = []
    entry = record_disposition(queue[0], "analyst.m.ali", "escalate", "repeated cancels ahead of opposite fill", log)
    print(entry["alert_id"], entry["decision"], entry["analyst"], str(entry["evidence_sha256"])[:12])
    with open("evidence_A-1001.json", "w") as f:
        json.dump(chart_payload(alerts[0], orders), f)

On its sample data the run prints the calibrated escalation rate for each bucket: 1.1%, 4.0%, 18.8% and 41.9%. Alert A-1001, where one trader placed 14 sell orders that were all cancelled within about two seconds and then bought 8,000, lands in the top bucket for full review. Alert A-1002, ordinary buying by another trader, lands in batch review. The analyst escalates A-1001 and the log records the decision with a SHA-256 hash of the evidence pack.

The points are deliberately simple so an analyst can see why an alert sits where it does. If you swap them for a decision model, such as OpenAI's Decisions API covered in our deep dive on LLM classification and decision models, keep the calibration step. The queue should be ordered by what your analysts actually escalated, not by a model's confidence.

How do you chart the evidence with trading charts AI code?

The second sample is TypeScript for the analyst's screen, using Lightweight Charts 5.2.1 and the vertical-line plugin from TradingView's first official plugin packages, released on 16 September 2026 under Apache 2.0. It draws one-minute candles, every order at its own price level, fills as arrows and the alert window as two labelled lines. It reads the JSON the Python sample writes, and it passes strict type-checking with TypeScript 7.0.2.

evidence-chart.ts
// Analyst evidence chart for one surveillance alert: prices, the trader's orders and the alert window.
// TypeScript 7.0.2, lightweight-charts 5.2.1, @tradingview/lwc-plugin-vertical-line 1.0.0.
// The payload comes from alert_triage.py and contains sample data only.
import {
  CandlestickSeries,
  createChart,
  createSeriesMarkers,
  LineStyle,
  type AutoscaleInfo,
  type IChartApi,
  type SeriesMarker,
  type UTCTimestamp,
} from 'lightweight-charts';
import { VerticalLine } from '@tradingview/lwc-plugin-vertical-line';

export interface EvidenceOrder {
  id: string;
  side: 'buy' | 'sell';
  price: number;
  qty: number;
  time: number; // start of the one-minute bar the order was placed in, Unix seconds
  cancelled: boolean;
  filled: number;
}

export interface Evidence {
  alert: { id: string; scenario: string; start: number; end: number };
  candles: Array<{ time: number; open: number; high: number; low: number; close: number }>;
  orders: EvidenceOrder[];
}

export function renderEvidence(container: HTMLElement, ev: Evidence): IChartApi {
  const chart = createChart(container, {
    autoSize: true,
    layout: { background: { color: '#0b1224' }, textColor: '#cbd5e1' },
    grid: { vertLines: { color: '#1e293b' }, horzLines: { color: '#1e293b' } },
    timeScale: { timeVisible: true, secondsVisible: false },
  });

  // Orders resting away from the market are the evidence, so the price scale must include them.
  const orderPrices = ev.orders.map((o) => o.price);
  const lo = Math.min(...orderPrices);
  const hi = Math.max(...orderPrices);
  const prices = chart.addSeries(CandlestickSeries, {
    upColor: '#26a69a', downColor: '#ef5350', wickUpColor: '#26a69a', wickDownColor: '#ef5350', borderVisible: false,
    autoscaleInfoProvider: (base: () => AutoscaleInfo | null): AutoscaleInfo | null => {
      const info = base();
      if (info?.priceRange) {
        info.priceRange.minValue = Math.min(info.priceRange.minValue, lo);
        info.priceRange.maxValue = Math.max(info.priceRange.maxValue, hi);
      }
      return info;
    },
  });
  prices.setData(ev.candles.map((c) => ({ ...c, time: c.time as UTCTimestamp })));

  // Each order sits at its own price: grey squares for cancelled orders, blue for live ones, amber arrows for fills.
  const markers: SeriesMarker<UTCTimestamp>[] = ev.orders
    .map((o): SeriesMarker<UTCTimestamp> =>
      o.filled > 0
        ? { time: o.time as UTCTimestamp, position: 'atPriceMiddle', price: o.price,
            shape: o.side === 'buy' ? 'arrowUp' : 'arrowDown', color: '#f59e0b',
            text: `${o.side} fill ${o.filled.toLocaleString('en-GB')}` }
        : { time: o.time as UTCTimestamp, position: 'atPriceMiddle', price: o.price,
            shape: 'square', color: o.cancelled ? '#94a3b8' : '#60a5fa', size: 0.6, id: o.id })
    .sort((a, b) => a.time - b.time);
  createSeriesMarkers(prices, markers);

  // The alert window, drawn with the official vertical-line plugin released on 16 September 2026.
  const edges: Array<[number, string]> = [[ev.alert.start, `${ev.alert.id} opens`], [ev.alert.end, 'window closes']];
  for (const [time, label] of edges) {
    prices.attachPrimitive(new VerticalLine(time as UTCTimestamp, {
      color: '#fb7185', width: 2, lineStyle: LineStyle.Dashed, snap: 'nearest', showLabel: false,
      badge: { text: label, color: '#ffe4e6', backgroundColor: '#2a0f16' },
    }));
  }

  chart.timeScale().fitContent();
  return chart;
}
Trading chart rendered by the TypeScript sample with sample data: one-minute candles around a surveillance alert, grey squares for cancelled sell orders stepping up away from the market, an amber arrow for an 8,000 buy fill and dashed vertical lines marking where the alert window opens and closes.
The chart sample rendered in Chrome from the Python sample's output. All prices and orders are sample data.

Our first render taught us something. The cancelled sell orders sat above the candles and the chart's automatic price scale cut most of them off, which hid exactly the pattern an analyst needs to see. The autoscaleInfoProvider in the sample widens the scale to include every order price. Test your evidence charts with real alert shapes before analysts rely on them.

What breaks in production?

  • **History that teaches old habits.** Calibrating on past dispositions also learns past mistakes. Keep the second-review sample, track how often second reviewers overturn a closure, and run a periodic blind re-review of a random batch.
  • **Volume spikes.** On volatile days alert counts jump and batch review grows. Watch queue age by bucket and add capacity before the backlog, not after.
  • **Incomplete evidence.** Replica lag, missing amendments or venue clocks out of step can make an alert look harmless. Record data completeness in the evidence pack and route incomplete packs to full review.
  • **Rubber-stamped batches.** If batch reviews take seconds each, the sample of second reviews will show it. Treat a rising overturn rate as an incident.
  • **Time zones on the chart.** Store timestamps in UTC and display UK time, which moves between GMT and BST, with a formatter on the chart. Analysts reading the wrong hour is a real failure mode.
  • **Charting licence terms.** Lightweight Charts requires the attribution notice from its NOTICE file and a link to TradingView. Its default attribution logo satisfies the link requirement, so do not switch it off unless you meet that requirement elsewhere.

Which prioritiser should you choose for trade surveillance AI?

Start with the simplest one your compliance team can explain, measure it, and only add a model when the numbers say it helps.

Prioritiser options for surveillance alert triage
OptionStrengthWatch out for
Vendor alert score onlyNo extra buildNot calibrated to your analysts' decisions
Transparent points plus calibration (as above)Easy to explain to compliance and auditorsNeeds re-calibration as scenarios change
Decision model, for example the Decisions API or a self-hosted modelCan read free text such as chat or voice transcriptsNeeds calibration, residency checks and robustness testing
Classifier trained on past dispositionsStrong when you have years of labelled alertsLearns historical bias, retrain with care
LLM narrative summary for the analystSaves reading time on complex alertsA summary is not evidence, keep the raw data one click away

How can an AI Agency London team help your surveillance desk?

Begin with the alerts your analysts spend most time on and measure today's queue age, closure time and escalation rate per scenario. Then add the evidence pack and the chart before any model, because those two save the most time with the least risk. Our AI Agency London engineers have built surveillance tooling for capital markets, and we deliver one triage workflow live, with your analysts deciding every alert, in a 14-day Proof Run. Our case studies describe the kind of operations work we do, and our blog covered how agents now fix reconciliation breaks with people approving. For the charting engine behind real-time alerts, see our playbook on building a real-time alerts engine for trading charts.

Frequently asked questions

How does trade surveillance work?

A surveillance system watches orders and trades for patterns linked to market abuse, such as layering, wash trades or trading ahead of announcements, and raises alerts. Analysts then review each alert with the underlying data and either close it with a reason or escalate it to compliance, which decides whether a suspicious transaction and order report is needed.

Can AI close trade surveillance alerts?

In the design in this playbook, no. AI builds the evidence and decides the order in which analysts see alerts. Every alert ends with a named analyst's decision, and a sample of quick closures is checked again by a second analyst. Your firm stays responsible for its surveillance arrangements.

What is alert triage in trade surveillance?

It is deciding which alerts get a full investigation first and which can be reviewed in batches. Good triage uses evidence about the alert, such as cancel ratios, order lifetimes and fills on the other side, and evidence about the past, such as how often similar alerts were escalated.

Why chart the evidence for surveillance analysts?

Because many abuse patterns are easier to judge visually. A chart showing prices, the trader's orders at their own price levels and the alert window lets an analyst see in seconds whether orders stepped away from the market and were cancelled before a fill on the other side.

References

  1. Financial Conduct Authority, "Building the next generation of market infrastructure (speech by Nikhil Rathi)", 22 September 2026, published 23 September 2026. https://www.fca.org.uk/news/speeches/building-next-generation-market-infrastructure
  2. TradingView (GitHub), "Plugin packages: 2026-09-16", 16 September 2026. https://github.com/tradingview/lightweight-charts/releases/tag/plugins-2026-09-16
  3. TradingView (GitHub), "Lightweight Charts v5.2.1", 12 August 2026. https://github.com/tradingview/lightweight-charts/releases/tag/v5.2.1
  4. jsDelivr (npm package), "@tradingview/lwc-plugin-vertical-line 1.0.0", 16 September 2026. https://www.jsdelivr.com/package/npm/@tradingview/lwc-plugin-vertical-line
  5. OpenAI, "Decisions (API guide)", Page checked 9 October 2026. https://developers.openai.com/api/docs/guides/decisions
  6. Microsoft (GitHub), "TypeScript 7.0.2 release", Published to npm 8 July 2026, release notes 20 August 2026. https://github.com/microsoft/TypeScript/releases/tag/v7.0.2