Skip to content
AI Integration

AI Trading Recommendations Need A Qualified Reviewer From 12 November: The Finance Clause In Anthropic's New Usage Policy, And The Gate In Code

On 8 October 2026 Anthropic published a Usage Policy update, in force from 12 November, that names personalised investment recommendations as a High-risk use of Claude: a qualified person must review them first, and the client must be told AI was used. The coverage went to a new rule on abusing the model, while FM Intelligence had found Claude named in nine of ten broker and platform AI-agent launches in the first half of 2026. This guide builds the control in TypeScript: a structured-output classifier, a qualified-reviewer queue with an append-only audit log, an order guard that only carries out client-confirmed tickets, and a Lightweight Charts v5 overlay that never draws an unreviewed recommendation.

AlchmAI Engineering24 min read

11

High-risk areas in Anthropic's Usage Policy from 12 November 2026. Finance is one; credit and insurance are listed separately

9 of 10

Broker and platform AI-agent launches tracked by FM Intelligence in the first half of 2026 that named Claude

56%

Of retail investors already use or are open to AI tools to help pick or alter investments, in eToro's survey of 11,000 across 13 countries

35 days

Between Anthropic publishing the update on 8 October 2026 and the new text taking effect on 12 November 2026

From 12 November 2026, if a Claude-powered trading feature tells a client what to buy, Anthropic's own Usage Policy says a qualified person must review that output before the client sees it, and the client must be told that AI was used. Anthropic published its 2026 Usage Policy update on 8 October 2026. The rewritten High-risk section lists 11 areas, and its finance entry describes AI trading recommendations in plain words: 'a personalized recommendation to buy, sell, hold, or allocate among specific investment products, such as securities, funds, digital assets, or annuities'. Any broker, prop firm or trading app with Claude in front of clients now needs a gate between the model and the client, and it needs it working before that date.

Anthropic says the requirement is not new: its announcement states that the human-review and disclosure requirements 'haven't changed', and that the section was rewritten because users had often asked whether a given use case was covered. What changed is precision. Anthropic's August 2025 update said the requirements 'apply specifically when models' outputs are consumer-facing, and not for business to business interactions'. The November text has no such qualifier, a difference the analyst Rajesh Beri flagged on the day of publication. That is his reading, not Anthropic's, but it removes the easiest argument for leaving a feature out of scope. The headlines went elsewhere: The Verge mentioned financial uses once, in a list, and led with a ban on 'abusive or cruel behavior' toward Claude; The Register ran 'Anthropic asks users to stop being mean to Claude'; and the Hacker News thread on The Verge's story had 91 points and 231 comments when we checked on 14 October 2026. The finance clause went almost unremarked.

It should not have, because brokers spent 2026 wiring assistants into live accounts. Finance Magnates reported on 24 September 2026 that FM Intelligence counted at least 10 brokers and platform vendors that connected AI agents to live client accounts in the first half of the year, with Claude named in nine of the ten. On 8 October 2026, the day of Anthropic's update, eToro launched connectors for Claude, ChatGPT, Grok and Grok Bot with read-and-act access 'where available'. This guide builds the control in four parts: a classifier, a qualified-reviewer queue with an append-only audit log, an order guard that lets an agent carry out only a decision the client already made, and a Lightweight Charts view that never draws an unreviewed recommendation. It is the pattern AlchmAI calls Signed-off analysis: code detects, a model explains, a wording check runs, a person reviews. The policy now effectively requires that last step for anything that reaches a recommendation.

1. How Do You Tell AI Trading Recommendations From Analysis?

The finance definition reduces to three questions a machine can ask of any output. Is it personalised to one individual? Does it identify specific investment products? Does it steer towards buying, selling, holding or allocating? The destination decides the rest. The same sentence is excluded internal drafting while it sits in an account manager's tool, and a High-risk AI Recommendation the moment it is pasted into a client email. Classification therefore has to run at every boundary the text crosses, not once when the model generates it.

Five example outputs and the class each gets. The first appears only as an example of blocked output.

  • HIGH_RISK: 'Your account is 40% cash. You may want to consider adding to [named share] before Thursday's results.' Personalised, a specific product, an action. It waits for a qualified reviewer or it does not go out.
  • DESCRIPTIVE: 'Over the last 20 sessions price traded in a range of 1.0848 to 1.0904 and tested the upper bound 3 times.' No individual, no action, every number computed by code.
  • EXCLUDED_INTERNAL: 'Three clients on the desk hold concentrated positions in the same sector.' Excluded while it stays with the account manager; re-classified if any of it heads to a client.
  • EXCLUDED_OPERATIONS: 'Overnight, 312 accounts crossed the margin alert threshold.' A report on the book that advises or decides nothing about one person.
  • EXECUTING_USER_DECISION: 'Send the order on ticket T-88412, confirmed by the client at 14:02:11.' Decided by code from a signed ticket, never by the model.

The classifier runs in two passes. Deterministic code goes first, with broad patterns for directive phrasing, personal context and symbols from the instrument master. A model then labels the output through structured outputs, using the Anthropic TypeScript SDK's messages.parse with a Zod schema, so the label is validated before the gate reads it. Two rules carry the design: the model may raise the risk level but never lower it below what the rules found, and any failure, refusal or truncated response routes the output to a person. A classifier that fails open is not a control.

typescriptgate/classifyOutput.ts
// @anthropic-ai/sdk 0.133.0, zod 4.6.7, Node 20+
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";

export type GateClass =
  | "HIGH_RISK" | "EXCLUDED_INTERNAL" | "EXCLUDED_OPERATIONS" | "EXECUTING_USER_DECISION" | "DESCRIPTIVE";

/** Where the text is about to go. Classify at every boundary, not once at generation. */
export type Destination = "client" | "internal_staff" | "operations";

export interface OutputContext {
  outputId: string;
  text: string;
  destination: Destination;
  hasClientPortfolioContext: boolean;     // were this client's holdings or balances in the prompt?
  confirmedTicketId?: string;             // set by the order path from a signed ticket, never by a model
  instrumentMaster: ReadonlySet<string>;  // e.g. "EURUSD", "GBPUSD"
}

const Label = z.object({
  personalised: z.boolean().describe("Applies to one identifiable person's holdings, balances, goals or circumstances"),
  instruments: z.array(z.string()).describe("Specific investment products named or clearly identified"),
  action: z.enum(["buy", "sell", "hold", "allocate", "none"]).describe("Action the text steers the reader towards"),
  aboutSpecificIndividual: z.boolean().describe("Advises on, or decides something about, a specific individual"),
  evidence: z.string().describe("Shortest exact quote supporting the action label, or empty"),
});
export type Label = z.infer<typeof Label>;

export interface Classification {
  outputId: string; gateClass: GateClass; label: Label | null;
  ruleHits: string[]; modelId: string; reason: string;
}

const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const MODEL = process.env.GATE_CLASSIFIER_MODEL ?? "claude-opus-5-5";
const SYSTEM =
  "You label one piece of text produced by an AI feature in a trading product. Do not rewrite or judge it. " +
  "Decide only whether it steers one person towards buying, selling, holding or allocating among specific investment products. " +
  "General explanation of how markets work, or description of what price has done, is not a recommendation.";

// Code detects first. Broad on purpose: a false positive costs a reviewer a minute.
const DIRECTIVE = /\b(you (?:should|could|might|may want to)|consider (?:buying|selling|adding|trimming|holding|switching)|we (?:recommend|suggest)|time to (?:buy|sell|add|exit)|rebalance (?:into|towards)|allocate \d{1,3}\s?%)/i;
const PERSONAL = /\b(your (?:portfolio|account|positions?|holdings?|allocation|balance|cash|isa|pension)|in your case)\b/i;
const SYMBOL = /\b[A-Z][A-Z0-9]{1,9}(?:[./][A-Z0-9]{1,6})?\b/g;

function ruleScan(ctx: OutputContext): string[] {
  const hits: string[] = [];
  if (DIRECTIVE.test(ctx.text)) hits.push("directive_phrase");
  if (PERSONAL.test(ctx.text) || ctx.hasClientPortfolioContext) hits.push("personal_context");
  const symbols = (ctx.text.match(SYMBOL) ?? []).map((s) => s.replace("/", ""));
  if (symbols.some((s) => ctx.instrumentMaster.has(s))) hits.push("named_instrument");
  return hits;
}

async function labelWithModel(ctx: OutputContext): Promise<Label | null> {
  try {
    const res = await client.messages.parse({
      model: MODEL,
      max_tokens: 4000,
      system: SYSTEM,
      output_config: { effort: "low", format: zodOutputFormat(Label) },
      messages: [{ role: "user", content: `Destination: ${ctx.destination}\n---\n${ctx.text}` }],
    });
    if (res.stop_reason === "refusal" || res.stop_reason === "max_tokens") return null;
    return res.parsed_output ?? null;
  } catch (err) {
    if (err instanceof Anthropic.APIError) return null; // outage, rate limit, bad request: fail closed
    throw err;
  }
}

export async function classifyOutput(ctx: OutputContext): Promise<Classification> {
  const ruleHits = ruleScan(ctx);
  const base = { outputId: ctx.outputId, ruleHits, modelId: MODEL };

  // Carrying out a decision the client already made is decided by code; OrderGuard verifies the ticket.
  if (ctx.confirmedTicketId) {
    return { ...base, gateClass: "EXECUTING_USER_DECISION", label: null, reason: `ticket ${ctx.confirmedTicketId}` };
  }
  const label = await labelWithModel(ctx);
  if (!label) return { ...base, gateClass: "HIGH_RISK", label, reason: "no label: fail closed to review" };

  const byRules = ["directive_phrase", "personal_context", "named_instrument"].every((h) => ruleHits.includes(h));
  const byModel = label.personalised && label.action !== "none" &&
    (label.instruments.length > 0 || ruleHits.includes("named_instrument"));
  const recommendation = byRules || byModel; // the model can raise the risk level, never lower it

  if (ctx.destination === "internal_staff") {
    return { ...base, gateClass: "EXCLUDED_INTERNAL", label,
      reason: recommendation ? "internal draft, recommendation-shaped: re-check at delivery" : "internal analysis" };
  }
  if (recommendation) {
    return { ...base, gateClass: "HIGH_RISK", label, reason: `personalised ${label.action}: "${label.evidence}"` };
  }
  if (ctx.destination === "operations" && !label.aboutSpecificIndividual) {
    return { ...base, gateClass: "EXCLUDED_OPERATIONS", label, reason: "operations, no specific individual" };
  }
  return { ...base, gateClass: "DESCRIPTIVE", label, reason: "no personalised action on a specific product" };
}

The patterns are tuned for recall, not precision: a false positive costs a reviewer a minute, while a false negative puts an unreviewed recommendation in a client's inbox. The evidence field gives the reviewer the exact phrase behind the label rather than a score, and the model ID is an environment variable so the classifier can be pinned and re-tested separately from the feature's own model.

How Does This Sit With The FCA's Advice Boundary And Consumer Duty?

For a UK firm the policy is an extra layer, not a substitute; Anthropic's text says the requirements 'do not replace local legal requirements and restrictions'. The FCA and HM Treasury's Advice Guidance Boundary Review examines 'the regulatory boundary between financial advice and other forms of support', and on 26 February 2026 the FCA Board confirmed final rules for targeted support, which lets firms with the new permission offer suggestions to groups of customers with common characteristics. The Consumer Duty 'requires firms to put their customers' needs first'. Finance Magnates notes that the FCA has not written rules for AI agents on client accounts, and when the Mills Review was published on 6 July 2026 the FCA's chair, Ashley Alder, described the regulator's approach to AI as 'relying on the Consumer Duty and Senior Managers Regime'.

Anthropic's policy sits on top as a contractual term from the model provider. The tests use different words and were written by different bodies, so passing one does not prove you pass the other. Map each gate class to both with your compliance lead; this guide is engineering, not legal advice.

2. What Does A Qualified Reviewer Have To Record?

Two phrases in the policy are the ones to engineer for: 'meaningfully review' and 'the authority to change or modify it'. A reviewer who can only click approve has no authority to change anything, so the queue offers approve, edit and reject. An approval four seconds after an item appeared is not meaningful, so the queue records when the deciding reviewer opened it and refuses faster approvals. Items expire, because a recommendation drafted against this morning's prices should not reach a client this afternoon.

The policy defines a qualified person as 'someone with the training or experience to evaluate the output in that field, and who holds a license where applicable law requires one for the underlying activity', and that person 'remains responsible for the accuracy and appropriateness of what is delivered or decided'. So every decision records the reviewer, the qualification on file, any licence reference, the seconds the item was open, and hashes of the draft and final text, which prove what the model wrote and what the reviewer changed without copying client content into a second store.

A firm without permission to give personal recommendations should not be approving them at all. For that firm the queue runs reject-only, and a queue that fills up every day is telling you the feature gives advice.

typescriptgate/reviewQueue.ts
// Node 20+
import { createHash } from "node:crypto";
import type { Classification } from "./classifyOutput";

export const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");

/** Append-only and hash-chained: deleting or editing a record breaks every hash after it. */
export class AuditLog {
  private lastHash = "GENESIS";
  private seq = 0;
  // `sink` writes to storage the app can insert into but never update or delete.
  constructor(private readonly sink: (r: object) => Promise<void>) {}

  async append(event: { kind: string } & Record<string, unknown>): Promise<void> {
    const at = new Date().toISOString();
    const seq = ++this.seq;
    const hash = sha256(`${seq}|${at}|${this.lastHash}|${JSON.stringify(event)}`);
    await this.sink({ seq, at, prevHash: this.lastHash, hash, event }); // no audit, no delivery
    this.lastHash = hash;
  }
}

export interface Reviewer {
  id: string;
  qualification: string;            // as held in the firm's competence records
  licenceRef: string | null;        // where applicable law requires a licence for the activity
  mayReviewInvestments: boolean;
  active: boolean;
}

export interface PendingReview {
  clientId: string; draft: string; c: Classification;
  expiresAt: number; openedBy?: string; openedAt?: number;
}

export type ReviewDecision =
  | { decision: "approved" } | { decision: "edited"; finalText: string } | { decision: "rejected"; note: string };

export type ReviewOutcome =
  | { deliver: true; clientId: string; text: string } | { deliver: false; reason: string };

export class ReviewQueue {
  private pending = new Map<string, PendingReview>();

  constructor(
    private readonly audit: AuditLog,
    private readonly reviewers: ReadonlyMap<string, Reviewer>,
    private readonly cfg = { ttlMinutes: 30, minSecondsOpen: 20, rejectOnly: false },
  ) {}

  async enqueue(clientId: string, draft: string, c: Classification): Promise<void> {
    const expiresAt = Date.now() + this.cfg.ttlMinutes * 60_000;
    this.pending.set(c.outputId, { clientId, draft, c, expiresAt });
    await this.audit.append({ kind: "queued", outputId: c.outputId, clientId, expiresAt });
  }

  /** Called by the reviewer UI. The clock for a meaningful review starts here. */
  open(outputId: string, reviewerId: string): PendingReview {
    const item = this.pending.get(outputId);
    if (!item) throw new Error(`no pending review ${outputId}`);
    Object.assign(item, { openedBy: reviewerId, openedAt: Date.now() });
    return item;
  }

  async decide(outputId: string, reviewerId: string, d: ReviewDecision): Promise<ReviewOutcome> {
    const item = this.pending.get(outputId);
    const reviewer = this.reviewers.get(reviewerId);
    if (!item) throw new Error(`no pending review ${outputId}`);
    if (!reviewer?.active || !reviewer.mayReviewInvestments) throw new Error(`${reviewerId} is not a qualified reviewer`);
    if (item.openedBy !== reviewerId || item.openedAt === undefined) throw new Error("open the item before deciding");

    const secondsOpen = Math.round((Date.now() - item.openedAt) / 1000);
    let decision = d;
    if (Date.now() > item.expiresAt) decision = { decision: "rejected", note: "expired before review" };
    else if (this.cfg.rejectOnly) decision = { decision: "rejected", note: d.decision === "rejected" ? d.note : "reject-only firm" };
    else if (d.decision !== "rejected" && secondsOpen < this.cfg.minSecondsOpen) {
      throw new Error(`open for ${secondsOpen}s; minimum for an approval is ${this.cfg.minSecondsOpen}s`);
    }

    const finalText = decision.decision === "approved" ? item.draft
      : decision.decision === "edited" ? decision.finalText : null;
    await this.audit.append({
      kind: "reviewed", outputId, reviewerId, qualification: reviewer.qualification, licenceRef: reviewer.licenceRef,
      decision: decision.decision, secondsOpen, draftSha256: sha256(item.draft),
      finalSha256: finalText && sha256(finalText), note: decision.decision === "rejected" ? decision.note : "",
    });
    this.pending.delete(outputId);
    return finalText === null
      ? { deliver: false, reason: decision.decision === "rejected" ? decision.note : "rejected" }
      : { deliver: true, clientId: item.clientId, text: finalText };
  }
}
  • Back the sink with a table the application role can insert into but never update or delete, or object storage under a retention lock. If the audit write fails, delivery fails with it.
  • Track each reviewer's approval rate and median time open. Approving 99% of items in under 30 seconds is a rubber stamp, and the numbers show it before a complaint does.

3. How Do You Stop An Agent Placing An Order Nobody Decided?

The policy covers a recommendation 'used to implement a decision' as well as one delivered as advice, and it makes whoever builds or deploys an agent responsible for 'actions those agents take through tools, browsers, or connected systems'. Brokers have drawn the agent line in different places. Finance Magnates describes CMC Markets' ChatGPT link as read-only, so 'the chatbot cannot place, change or cancel orders, or move money'. At Interactive Brokers a client still has to turn each AI-drafted instruction into an order. ThinkMarkets' MCP server lets AI assistants execute trades but blocks access to client funds, and Leverate's MCP for Traders sends orders once the trader approves them. eToro's new connectors propose every action for review and approval, keep funding and withdrawals out of the chat, and are not available to clients in the US, Australia or Singapore.

The exclusion that makes agent execution workable is 'Carrying out a decision a person has already made', and the engineering translation is strict. The order tool accepts only a ticket the client confirmed in the platform's own ticket screen, signed on the server, single use and short-lived, and the order must match it field for field. An agent that changes the quantity, side or limit price is making a decision, not carrying one out, so the guard rejects it. A notional limit, a per-client rate limit and a kill switch sit in front of the broker, and every rejection is audited with its reason.

The same file holds the text gate. Descriptive output passes a wording check or is replaced by a safe fallback, excluded classes cannot reach a client, and high-risk output waits in the queue. The AI disclosure is stamped only on the release path, so no client sees an approved recommendation without it. That per-message disclosure is separate from the policy's rule that consumer-facing chatbots and AI agents talking to external users disclose that users are interacting with AI 'at a minimum at the beginning of each chat session or in the product interface'.

typescriptgate/recommendationGate.ts
// Node 20+. Sits between every AI feature and the client, and between every agent and the order API.
import { createHmac, timingSafeEqual } from "node:crypto";
import { classifyOutput, type OutputContext } from "./classifyOutput";
import { AuditLog, ReviewQueue, sha256, type ReviewDecision } from "./reviewQueue";

export const AI_DISCLOSURE =
  "AI was used to produce this message. A qualified member of our team reviewed it, and could change it, before it was sent.";
const FALLBACK = "An AI summary is not available for this view. Your chart and account data are unaffected.";

export interface KillSwitch { isOn(scope: "ai_client_output" | "agent_orders"): Promise<boolean> }

const BLOCKED_WORDING: ReadonlyArray<[RegExp, string]> = [
  [/\b(buy|sell|short|go long|go short|accumulate|take profit)\b/i, "action wording"],
  [/\b(you should|consider|we recommend|we suggest)\b/i, "recommendation wording"],
  [/\b(price target|target of|stop(?:-loss)? at)\b/i, "price target or trade level"],
  [/\b(will (?:rise|fall|break|reach)|is going to|poised to|likely to)\b/i, "prediction"],
  [/\b(guaranteed?|risk[- ]free|can't lose)\b/i, "promised outcome"],
  [/\b(bullish|bearish|breakout)\b/i, "signal or opinion wording"],
];
export const wordingCheck = (text: string) => BLOCKED_WORDING.filter(([re]) => re.test(text)).map(([, why]) => why);

export type GateResult =
  | { status: "delivered" | "internal_only"; text: string }
  | { status: "held_for_review"; outputId: string }
  | { status: "blocked"; reason: string; fallback: string };

export class RecommendationGate {
  constructor(private readonly audit: AuditLog, private readonly queue: ReviewQueue, private readonly kill: KillSwitch) {}

  private async block(outputId: string, reason: string): Promise<GateResult> {
    await this.audit.append({ kind: "blocked", outputId, reason });
    return { status: "blocked", reason, fallback: FALLBACK };
  }

  async handle(ctx: OutputContext & { clientId: string }): Promise<GateResult> {
    if (await this.kill.isOn("ai_client_output")) return this.block(ctx.outputId, "kill switch");
    const c = await classifyOutput(ctx);
    await this.audit.append({ kind: "classified", ...c, textSha256: sha256(ctx.text) });

    switch (c.gateClass) {
      case "DESCRIPTIVE": {
        const problems = wordingCheck(ctx.text);
        return problems.length ? this.block(c.outputId, `wording: ${problems.join(", ")}`) : { status: "delivered", text: ctx.text };
      }
      case "EXCLUDED_INTERNAL":
      case "EXCLUDED_OPERATIONS":
        return ctx.destination === "client"
          ? this.block(c.outputId, "excluded class cannot reach a client")
          : { status: "internal_only", text: ctx.text };
      case "EXECUTING_USER_DECISION":
        return this.block(c.outputId, "execution goes through OrderGuard, not the text channel");
      case "HIGH_RISK":
        await this.queue.enqueue(ctx.clientId, ctx.text, c);
        return { status: "held_for_review", outputId: c.outputId };
    }
  }

  /** The only path that delivers a reviewed recommendation, and the only place the disclosure is stamped. */
  async release(outputId: string, reviewerId: string, d: ReviewDecision): Promise<GateResult> {
    const out = await this.queue.decide(outputId, reviewerId, d);
    if (!out.deliver) return this.block(outputId, `reviewer: ${out.reason}`);
    const text = `${out.text}\n\n${AI_DISCLOSURE}`;
    await this.audit.append({ kind: "delivered", outputId, clientId: out.clientId, finalSha256: sha256(text) });
    return { status: "delivered", text };
  }
}

/** Created by the client's own order ticket screen and signed server side. A model never creates one. */
export interface ConfirmedTicket {
  ticketId: string; clientId: string; symbol: string; side: "buy" | "sell";
  quantity: number; limitPrice: number | null; expiresAt: number; signature: string;
}
export type AgentOrderCall = Omit<ConfirmedTicket, "expiresAt" | "signature">;
export interface TicketStore { get(id: string): Promise<ConfirmedTicket | null>; markUsed(id: string): Promise<boolean> }
export interface Broker { placeOrder(o: AgentOrderCall): Promise<{ brokerOrderId: string }> }

const LIMITS = { maxNotionalGbp: 25_000, maxOrdersPerClientPerMinute: 3 };
const KEY = process.env.TICKET_SIGNING_KEY ?? "";
if (KEY.length < 32) throw new Error("TICKET_SIGNING_KEY missing or too short");

export const signTicket = (t: Omit<ConfirmedTicket, "signature">) =>
  createHmac("sha256", KEY).update([t.ticketId, t.clientId, t.symbol, t.side, t.quantity, t.limitPrice ?? "MKT", t.expiresAt].join("|")).digest("hex");

export class OrderGuard {
  private recent = new Map<string, number[]>();

  constructor(
    private readonly audit: AuditLog, private readonly kill: KillSwitch, private readonly tickets: TicketStore,
    private readonly broker: Broker, private readonly priceGbp: (symbol: string) => Promise<number>,
  ) {}

  /** The only implementation behind the agent's place_order tool. */
  async placeOrder(call: AgentOrderCall): Promise<{ ok: true; brokerOrderId: string } | { ok: false; reason: string }> {
    const reject = async (reason: string) => {
      await this.audit.append({ kind: "order_rejected", ticketId: call.ticketId, clientId: call.clientId, reason });
      return { ok: false as const, reason };
    };
    if (await this.kill.isOn("agent_orders")) return reject("kill switch");

    const t = await this.tickets.get(call.ticketId);
    if (!t) return reject("no client-confirmed ticket: an agent may not originate an order");
    const want = Buffer.from(signTicket(t), "hex");
    const got = Buffer.from(t.signature, "hex");
    if (got.length !== want.length || !timingSafeEqual(got, want)) return reject("ticket signature invalid");
    if (Date.now() > t.expiresAt) return reject("ticket expired");

    // Executing means matching the decision exactly. Any drift means the agent is deciding.
    const drift = (["clientId", "symbol", "side", "quantity", "limitPrice"] as const).filter((k) => t[k] !== call[k]);
    if (drift.length) return reject(`differs from ticket on ${drift.join(", ")}`);

    const notional = t.quantity * (t.limitPrice ?? (await this.priceGbp(t.symbol)));
    if (notional > LIMITS.maxNotionalGbp) return reject(`notional ${Math.round(notional)} GBP over limit`);
    const now = Date.now();
    const window = (this.recent.get(t.clientId) ?? []).filter((ts) => now - ts < 60_000);
    if (window.length >= LIMITS.maxOrdersPerClientPerMinute) return reject("rate limit");
    if (!(await this.tickets.markUsed(t.ticketId))) return reject("ticket already used");
    this.recent.set(t.clientId, [...window, now]);

    const { brokerOrderId } = await this.broker.placeOrder(call);
    await this.audit.append({ kind: "order_sent", ticketId: t.ticketId, clientId: t.clientId, brokerOrderId });
    return { ok: true, brokerOrderId };
  }
}

Where clients bring their own assistant through a connector, as with the eToro and CMC Markets launches, who counts as the user under Anthropic's policy is a question for your lawyers. What the broker controls in every arrangement is the tool surface, and the order guard is that surface.

4. Can Trading Charts Show AI Analysis Without Giving Advice?

A chart is where AI output does the most persuading with the fewest words. An arrow under a candle reads as an instruction whatever its label says, so this overlay uses no arrows: squares, circles, dashed price lines and short factual labels. Code finds the facts, such as a range, its bounds and how often price tested them. The model labels those facts only, and each label is checked number by number before it is drawn, so 'range 1.0848 to 1.0904, tested 3 times' renders and a label with a number the code never produced does not.

The gate's classification travels with each annotation and the chart never re-decides it. DESCRIPTIVE annotations render; HIGH_RISK annotations render only after a reviewer approves them, under a legend saying a qualified person reviewed them; everything else stays off a client chart. In Lightweight Charts v5, markers are a plugin created with createSeriesMarkers and replaced wholesale with setMarkers, and price lines belong to the series through createPriceLine and removePriceLine, so a re-render after an approval or a rejection is a clean swap rather than a diff.

typescriptchart/descriptiveOverlay.ts
// lightweight-charts 5.2.1 (v5 API: addSeries, createSeriesMarkers, createPriceLine)
import {
  CandlestickSeries, LineStyle, createChart, createSeriesMarkers,
  type CandlestickData, type IPriceLine, type ISeriesApi, type SeriesMarker, type UTCTimestamp,
} from "lightweight-charts";
import type { GateClass } from "../gate/classifyOutput";

/** Facts found by deterministic code. The model only writes the label. */
export type ChartFact =
  | { kind: "range"; low: number; high: number; tests: number; to: UTCTimestamp }
  | { kind: "test"; price: number; time: UTCTimestamp };

export interface ChartAnnotation {
  outputId: string;
  gateClass: GateClass;
  approved: boolean;   // set only by a qualified reviewer's release
  facts: ChartFact[];
  label: string;       // e.g. "range 1.0848 to 1.0904, tested 3 times"
  digits: number;
}

/** The gate decides; the chart never re-decides. HIGH_RISK is never drawn before approval. */
export const isRenderable = (a: ChartAnnotation) =>
  a.gateClass === "DESCRIPTIVE" || (a.gateClass === "HIGH_RISK" && a.approved);

/** Fact grounding: every number in the label must be one the code produced. */
export function labelMatchesFacts(a: ChartAnnotation): boolean {
  const allowed = new Set(a.facts.flatMap((f) =>
    f.kind === "range" ? [f.low.toFixed(a.digits), f.high.toFixed(a.digits), String(f.tests)] : [f.price.toFixed(a.digits)]));
  return (a.label.match(/\d+(?:\.\d+)?/g) ?? []).every((n) => allowed.has(n));
}

export function mountDescriptiveOverlay(el: HTMLElement, legend: HTMLElement, bars: CandlestickData<UTCTimestamp>[]) {
  const chart = createChart(el, { autoSize: true, layout: { background: { color: "#0b0f17" }, textColor: "#cbd5e1" } });
  const candles: ISeriesApi<"Candlestick"> = chart.addSeries(CandlestickSeries, {
    upColor: "#26a69a", downColor: "#ef5350", borderVisible: false, wickUpColor: "#26a69a", wickDownColor: "#ef5350",
  });
  candles.setData(bars);
  const markers = createSeriesMarkers(candles, []);
  let lines: IPriceLine[] = [];

  function render(annotations: ChartAnnotation[]) {
    lines.forEach((l) => candles.removePriceLine(l));
    lines = [];
    const shown = annotations.filter((a) => isRenderable(a) && labelMatchesFacts(a));
    const next: SeriesMarker<UTCTimestamp>[] = [];

    for (const a of shown) {
      a.facts.forEach((f, i) => {
        const id = `${a.outputId}:${i}`;
        if (f.kind === "range") {
          lines.push(candles.createPriceLine({ price: f.high, title: "range high", color: "#94a3b8", lineStyle: LineStyle.Dashed, lineWidth: 1 }));
          lines.push(candles.createPriceLine({ price: f.low, title: "range low", color: "#94a3b8", lineStyle: LineStyle.Dashed, lineWidth: 1 }));
          next.push({ id, time: f.to, position: "aboveBar", shape: "square", color: "#94a3b8", text: a.label });
        } else {
          // No arrows, ever: an arrow under a candle reads as an instruction whatever its label says.
          next.push({ id, time: f.time, position: "atPriceMiddle", price: f.price, shape: "circle", color: "#38bdf8", size: 0.6 });
        }
      });
    }
    markers.setMarkers(next.sort((x, y) => x.time - y.time));
    legend.textContent = shown.some((a) => a.gateClass === "HIGH_RISK")
      ? "AI was used to produce these notes. A qualified person reviewed them before display."
      : shown.length ? "AI-generated description of past price action. Descriptive, not advisory." : "";
    return { shown: shown.length, withheld: annotations.length - shown.length };
  }

  chart.timeScale().fitContent();
  return { render, destroy: () => chart.remove() };
}
  • Show the withheld count to staff, not clients. A chart withholding a third of its annotations has a prompt problem, not a rendering one.
  • Use atPriceMiddle markers with an explicit price for level tests. They sit on the level the code measured rather than on the bar's high or low.

“From 12 November, the line between AI analysis and AI advice is a qualified person with the authority to change the answer.”


Where This Belongs: Brokers, Prop Firms, Startups, Educators And Quant Teams

  1. 01Brokers and CFD providers: put the gate in front of every AI surface, including chart summaries, push notifications, assistant-drafted emails and MCP connectors, not only the chatbot.
  2. 02Prop trading firms: classify coaching and analytics features too. A message to one trader that names an instrument and an action has the three properties the policy describes, whatever the product team calls it.
  3. 03Fintech and trading startups: build the order guard on day one. Retrofitting it into an agent that already composes its own orders means rewriting the tool surface, and due diligence will ask how each agent order traces back to a client decision.
  4. 04Trading education and tools: most output should classify as DESCRIPTIVE under the exclusion for explaining 'how investing works, without applying it to the individual's circumstances'. The risk is the turn where a student uploads their own positions and asks what to do; that turn is personalised by definition, so route it to a reviewer or decline it.
  5. 05Research and quant teams: internal research is excluded while it stays internal. Tag each research output with its destination when it is created, so the gate can re-classify the paragraph that ends up in a client note.

The Bottom Line

AI trading recommendations are no longer a grey area for anyone building on Claude. Anthropic's Usage Policy, published on 8 October 2026 and in force from 12 November 2026, names personalised recommendations on specific investment products as a High-risk use, requires a qualified person with authority to change the output to review it before it is delivered or acted on, and requires the client to be told AI was used. The build is four pieces: a classifier that labels every output at every boundary and fails closed; a reviewer queue with a hash-chained audit log of who reviewed, with what qualification and for how long; an order guard that executes only signed, client-confirmed tickets under limits and a kill switch; and a chart overlay that never draws an unreviewed recommendation. That is the trading AI architecture AlchmAI, a London trading technology and AI studio, builds for brokers, prop firms and trading apps, and it is the difference between an AI feature a compliance lead can sign off and one they have to switch off.

References & Further Reading

AI trading recommendationsClaude usage policyConsumer Duty AITrading AI architecturetrading charts AIAI Automation Trading codehuman in the loop
Share Email
AI

AlchmAI Engineering

Engineering, London

Written by the AlchmAI engineering team in Mayfair, London. We build trading platforms, real-time charts, market data pipelines and AI features for brokers, prop firms and fintech teams. The Playbook is where we explain how we approach these systems, with code you can run and sources you can check.

Code in this guide is illustrative and supplied without warranty. Review and test it before production use. Nothing here is investment advice. Important information