Trading  ·  BraivIQ AI Engineering Playbook

Trading Automation For Surveillance Settings: Above And Below The Line Testing After Market Watch 86

Trading automation for surveillance is not only about raising alerts. It is about proving the settings behind them work. On 14 October 2026 the FCA's Market Watch 86 described retail brokers using a flat price move threshold whatever the instrument's volatility, and a minimum notional so high that client trades were never monitored. It named above and below the line testing and synthetic trade data as good practice. This playbook builds that test in Python, charts the result with Lightweight Charts in TypeScript, and puts a named owner and a calibration log in front of any change to a live setting.

Published  ·  Updated  ·  12 min read  ·  By BraivIQ Engineering

Candlestick chart on a dark trading screen, illustrating trading automation for testing surveillance thresholds

Key takeaways

  • Market Watch 86, published on 14 October 2026, found firms relying on vendor default settings, flat price move thresholds and minimum notional values set so high that client trades were not monitored.
  • The FCA named testing alerts at different thresholds through above and below the line testing, and synthetic trade data, as good practice.
  • In the sample, a flat 2% move with a £50,000 minimum misses 80 of 120 cases built to alert. Lowering the minimum catches them all but adds false alerts. A threshold scaled to volatility does best, partly because the synthetic data was built that way.
  • Treat a setting change like a code change: test evidence, a chart, a named approver and a calibration log.

7 - Retail brokers the FCA visited for Market Watch 86 (14 October 2026)  ·  80 of 120 - Synthetic cases built to alert that a flat 2% move with a £50k minimum missed in the sample  ·  240 - Synthetic cases in the test, across two instruments and three trade sizes  ·  0 - Real trades, prices or signals used. Synthetic data only.

Use trading automation to test the settings, not only to run them. Build synthetic cases whose right answer you know, run every candidate setting against them, count what each one catches, misses and falsely raises, and show the result to the person who owns the scenario. Change a live setting only after that person signs off, and keep the evidence. This is engineering for surveillance testing. It contains no trading signals or strategies, and every number comes from synthetic data.

The Python sample uses only the standard library and passes mypy 2.4.0 in strict mode on Python 3.14.4 and 3.11.14. The chart uses lightweight-charts 5.2.1 and passes TypeScript 7.0.2 in strict mode.

What did Market Watch 86 find?

The FCA visited 7 retail brokers. Some “appeared to rely on out-of-the-box settings provided by third-party vendors”, and some “mistakenly assumed that having an alert in place was evidence of an effective control”. It found a “flat price move threshold” applied regardless of large-cap or small-cap volatility and a “minimum notional trade value parameter that was set too high”. As good practice it named “testing alerts at different thresholds through ‘above’ and ‘below the line’ testing” and “using synthetic trade data to test whether alerts would trigger as expected in specific market abuse scenarios”. Our blog covered what Market Watch 86 means for brokers.

What does trading automation for threshold testing look like?

Trading automation diagram for surveillance threshold testing: synthetic cases with known right answers at three trade sizes run against candidate settings, a test run counts caught, missed and false alerts, an evidence chart plots every case against the threshold, a named owner approves a change, the live scenario changes only after sign-off and a calibration log keeps the setting, results, chart, approver and date.
Synthetic data only. Test the setting, not the trader. Tap to open full size.
calibration.py
"""Above and below the line testing for a surveillance scenario, with synthetic trades.

FCA Market Watch 86 (14 October 2026) found retail brokers using a flat price move threshold regardless of
large-cap or small-cap volatility, and a minimum notional so high that client trades were never
monitored. It names "above" and "below the line" testing and synthetic trade data as good practice.

The scenario here is a deliberately simple example (a trade followed by a large price move), not a
vendor rule and not a detection method to copy. The point is the test harness: generate cases whose
right answer you know, run each candidate setting, and count what it catches and what it misses.

Standard library only. Tested on Python 3.14.4 and 3.11.14 with mypy --strict. Synthetic data only.
"""

from __future__ import annotations

import json
import random
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Literal

Label = Literal["test_positive", "test_negative"]


@dataclass(frozen=True)
class Instrument:
    name: str
    daily_vol_pct: float  # used only to generate synthetic moves


@dataclass(frozen=True)
class Case:
    time: int  # UTC seconds, for the chart
    instrument: str
    notional_gbp: float
    move_pct: float
    label: Label  # what the case was built to be


@dataclass(frozen=True)
class Setting:
    name: str
    min_notional_gbp: float
    flat_move_pct: float | None = None  # a single threshold for every instrument
    vol_multiple: float | None = None  # or a threshold scaled to each instrument's volatility

    def alerts(self, case: Case, vol: dict[str, float]) -> bool:
        if case.notional_gbp < self.min_notional_gbp:
            return False
        limit = self.flat_move_pct if self.flat_move_pct is not None else (self.vol_multiple or 0) * vol[case.instrument]
        return abs(case.move_pct) >= limit


# Test positives: a move of 3 to 5 daily volatilities after the trade. Test negatives: under 1.
BUCKETS: tuple[tuple[Label, float, float], ...] = (("test_positive", 3.0, 5.0), ("test_negative", 0.1, 1.0))


def synthetic_cases(instruments: list[Instrument], per_bucket: int, seed: int = 7) -> list[Case]:
    """Each instrument gets positives and negatives at retail, larger retail and institutional sizes."""
    rng = random.Random(seed)
    cases: list[Case] = []
    t = 1_790_000_000
    for inst in instruments:
        for notional in (2_500.0, 15_000.0, 80_000.0):  # retail, larger retail, institutional size
            for _ in range(per_bucket):
                for label, lo, hi in BUCKETS:
                    move = round(rng.uniform(lo, hi) * inst.daily_vol_pct * rng.choice((-1, 1)), 2)
                    cases.append(Case(t, inst.name, notional, move, label))
                    t += 60
    return cases


@dataclass(frozen=True)
class Result:
    setting: str
    caught: int
    missed: int
    false_alerts: int
    missed_by_instrument: dict[str, int]


def run(setting: Setting, cases: list[Case], vol: dict[str, float]) -> Result:
    caught = missed = false_alerts = 0
    by_inst: dict[str, int] = {}
    for c in cases:
        fired = setting.alerts(c, vol)
        if c.label == "test_positive" and fired:
            caught += 1
        elif c.label == "test_positive":
            missed += 1
            by_inst[c.instrument] = by_inst.get(c.instrument, 0) + 1
        elif fired:
            false_alerts += 1
    return Result(setting.name, caught, missed, false_alerts, by_inst)


if __name__ == "__main__":
    instruments = [Instrument("LARGECAP", 1.2), Instrument("SMALLCAP", 4.5)]
    vol = {i.name: i.daily_vol_pct for i in instruments}
    cases = synthetic_cases(instruments, per_bucket=20)
    settings = [
        Setting("flat 2% move, min notional £50k", min_notional_gbp=50_000, flat_move_pct=2.0),
        Setting("flat 2% move, min notional £1k", min_notional_gbp=1_000, flat_move_pct=2.0),
        Setting("3x volatility, min notional £1k", min_notional_gbp=1_000, vol_multiple=3.0),
    ]
    positives = sum(c.label == "test_positive" for c in cases)
    print(f"{len(cases)} synthetic cases, {positives} built to alert")
    for s in settings:
        r = run(s, cases, vol)
        print(f"{r.setting:<34} caught {r.caught:>3}  missed {r.missed:>3}  false alerts {r.false_alerts:>3}  "
              f"missed by instrument {r.missed_by_instrument}")
    chosen = settings[0]
    rows = [{**asdict(c), "alerted": chosen.alerts(c, vol)} for c in cases if c.instrument == "SMALLCAP"]
    Path("calibration.json").write_text(json.dumps({"setting": chosen.name, "threshold_pct": 2.0, "cases": rows}))
    print(f"wrote calibration.json with {len(rows)} SMALLCAP cases for the chart")

Standard library only. Tested on Python 3.14.4 and 3.11.14 with mypy 2.4.0 --strict. Synthetic data.

With 240 cases, 120 built to alert, a flat 2% move with a £50,000 minimum catches 40 and misses 80: every retail-sized case is invisible, and large-cap moves below 2% are missed too. Dropping the minimum to £1,000 catches all 120 but raises 35 false alerts on volatile small-cap stock. A threshold of three times each instrument's volatility catches all 120 with none false. Be honest about why: the synthetic generator built positives at 3 to 5 volatilities and negatives below 1, so that setting is right by construction. Real calibration needs scenarios designed by your surveillance team.

How do you show the evidence to the scenario owner?

One bar per synthetic case, coloured by outcome, with the threshold drawn across it. Red bars are cases that should alert and did not, which is the below the line problem in one picture.

calibration_chart.ts
// Calibration chart for surveillance testing: every synthetic case as a bar of its price move, coloured by
// what the scenario did with it, against the threshold line. Reads calibration.json from calibration.py.
// lightweight-charts 5.2.1, TypeScript 7.0.2 (strict). Synthetic data only.
import { createChart, HistogramSeries, LineStyle, type HistogramData, type UTCTimestamp } from 'lightweight-charts';

interface CalibrationCase {
  time: number;
  instrument: string;
  notional_gbp: number;
  move_pct: number;
  label: 'test_positive' | 'test_negative';
  alerted: boolean;
}

interface CalibrationRun {
  setting: string;
  threshold_pct: number;
  cases: CalibrationCase[];
}

const COLOURS = {
  caught: '#10b981', // built to alert, and it did
  missed: '#f43f5e', // built to alert, and it did not: the "below the line" problem
  falseAlert: '#f59e0b', // built not to alert, and it did
  quiet: '#475569', // built not to alert, and it did not
} as const;

function colour(c: CalibrationCase): string {
  if (c.label === 'test_positive') return c.alerted ? COLOURS.caught : COLOURS.missed;
  return c.alerted ? COLOURS.falseAlert : COLOURS.quiet;
}

export function renderCalibration(container: HTMLElement, run: CalibrationRun): void {
  const chart = createChart(container, {
    height: 360,
    layout: { background: { color: '#0b1224' }, textColor: '#cbd5e1', fontSize: 15 },
    grid: { vertLines: { color: '#1e293b' }, horzLines: { color: '#1e293b' } },
    timeScale: { visible: false }, // synthetic timestamps only order the cases
  });
  const series = chart.addSeries(HistogramSeries, {
    priceFormat: { type: 'percent' },
    base: 0,
    lastValueVisible: false,
    priceLineVisible: false,
  });
  const data: HistogramData<UTCTimestamp>[] = run.cases.map((c) => ({
    time: c.time as UTCTimestamp,
    value: c.move_pct,
    color: colour(c),
  }));
  series.setData(data);
  for (const sign of [1, -1]) {
    series.createPriceLine({
      price: sign * run.threshold_pct,
      color: '#f59e0b',
      lineStyle: LineStyle.Dashed,
      lineWidth: 2,
      axisLabelVisible: true,
      title: sign === 1 ? run.setting : '',
    });
  }
  chart.timeScale().fitContent();
  const missed = run.cases.filter((c) => c.label === 'test_positive' && !c.alerted).length;
  container.setAttribute('aria-label', `${run.cases.length} synthetic cases, ${missed} missed by ${run.setting}`);
}

async function main(): Promise<void> {
  const response = await fetch('calibration.json');
  const run = (await response.json()) as CalibrationRun;
  const container = document.getElementById('chart');
  if (container === null) throw new Error('missing #chart element');
  renderCalibration(container, run);
}

void main();

lightweight-charts 5.2.1, TypeScript 7.0.2 strict, bundled with esbuild 0.28.2. Reads calibration.json from calibration.py.

Trading automation calibration chart rendered with Lightweight Charts: 120 synthetic small-cap cases as bars, with red bars for 40 cases that should have alerted but fell below the £50,000 minimum, green bars for caught cases, amber bars for false alerts and dashed threshold lines at plus and minus 2%.
Rendered from calibration.json in headless Chrome. Charting by TradingView Lightweight Charts.

What breaks in production?

  • **Synthetic data that flatters a setting.** As above, the generator can decide the answer. Have scenarios written by people who know the abuse type, not by the person tuning the threshold.
  • **Volatility measured badly.** A volatility-scaled threshold is only as good as the volatility estimate. Test thin and newly listed stocks separately.
  • **Alert volume used as proof.** The FCA called out comparing alert volumes to trading volumes. Count caught and missed test cases instead.
  • **Settings changed without a record.** Keep the setting, test results, chart, approver and date together, and review them on a schedule.

What are the alternatives to building your own test rig?

OptionGood fitTrade-off
Vendor calibration toolsFirms on a surveillance platform with built-in testingCheck the tool tests your settings, not only the vendor defaults
Historical replay onlyFirms with long, clean trade historyShows what fired, not what should have, so misses stay hidden
Synthetic tests, as aboveAny firm, and good for below the line testingScenario design takes surveillance expertise

Where should a broker start?

Write down the current settings for each scenario, generate synthetic cases around them at retail and institutional sizes, and count the misses. That one table answers the Market Watch 86 question: were your settings tested? Our trade surveillance alert triage playbook covers the queue after alerts fire, and our case studies include real-time surveillance for a global investment bank, where people made the call on what each alert meant. Our AI Agency London team can build the test rig with your surveillance team in a 14-day Proof Run.

Frequently asked questions

What is above and below the line testing?

It is testing a surveillance scenario with cases just above and just below its thresholds, to see whether it alerts when it should and stays quiet when it should. The FCA named it as good practice in Market Watch 86 on 14 October 2026.

Why use synthetic trade data for surveillance testing?

Because you know the right answer for each case. Historical data shows what alerted, not what should have, so missed abuse stays hidden. Synthetic cases let you count misses directly, as long as the scenarios are designed by people who know the behaviour.

Does this playbook contain trading strategies or signals?

No. It is engineering for testing surveillance settings. All prices, trades and instruments are synthetic, and nothing here suggests what to buy or sell.

References

  1. Financial Conduct Authority, "Market Watch 86", 14 October 2026. https://www.fca.org.uk/publications/newsletters/market-watch-86
  2. TradingView (GitHub), "Lightweight Charts releases", 2026. https://github.com/tradingview/lightweight-charts/releases
  3. jsDelivr, "lightweight-charts 5.2.1 package", 2026. https://www.jsdelivr.com/package/npm/lightweight-charts?version=5.2.1
  4. Financial Conduct Authority, "Three arrests in major FCA operation against criminal insider dealing ring", 13 October 2026. https://www.fca.org.uk/news/press-releases/three-arrests-major-fca-operation-against-criminal-insider-dealing-ring