CadenseCadense
  • Docs
  • Blog
  • Integrations
  • Pricing
  • Docs
  • Blog
  • Integrations
  • Pricing

What should we get done?

Docs
Cadense

Product

HarnessWorkflowsKnowledge BaseTablesMCPAPISelf HostingPricingChangelogFiles

Resources

BlogDocsPricingPartnersChangelog

Blocks

AgentRouterFunctionConditionAPIWorkflowParallelGuardrailsEvaluatorLoop

Integrations

All IntegrationsConfluenceSlackGitHubGmailHubSpotSalesforceNotionGoogle DriveGoogle SheetsSupabaseStripeJiraLinearAirtableFirecrawlDiscordMicrosoft TeamsTelegram

Legal

Terms of ServicePrivacy Policy
Back to Blog
Spotting unusual spend without crying wolf: Cadense's anomaly detector

Spotting unusual spend without crying wolf: Cadense's anomaly detector

Why a single $50k legal invoice in your baseline can blind a classical z-score, how robust statistics fix it, and the five signals Cadense uses to flag spend spikes without drowning AP teams in noise.

Apr 18, 2026
Wa
Waleed Latif

A controller I worked with showed me her old anomaly dashboard once. It had 1,800 unread alerts in it. She'd given up on it eight months earlier. The first ten alerts were real. The next 1,790 were noise.

That's the failure mode you have to design against. A spend anomaly detector will only earn trust twice. The first time it catches something that mattered, the AP team trusts it. The second time it cries wolf on what turns out to be normal seasonal spend, they tolerate it. The third false alarm and the dashboard becomes wallpaper.

We built Cadense's spend anomaly detector with one constraint above all others: silent is better than noisy. Every choice in the architecture exists to keep the signal-to-noise ratio high enough that a controller will keep opening the queue.

TL;DR

  • The detector runs daily over a rolling 90-day baseline (configurable 2–365).
  • It computes both classical stats (mean/stddev) and robust stats (median/MAD) so a single outlier in your history can't blind it.
  • Five independent signals — legacy z-score spike, robust z-score spike, flat baseline spike, rare spend spike, large transaction contributor. The maximum score wins.
  • Two noise filters — minimum amount floor and minimum category confidence — kill the long tail before it reaches the queue.
  • Anomalies are fingerprinted so re-runs upsert rather than duplicate.

The wolf problem, with numbers

Imagine your Legal Fees category has these daily spends over a 90-day baseline (most days $0, a $50k invoice on day 47):

1
[0, 0, 0, ..., 50000, ..., 0, 0, 0]   ← classical mean = $556, classical stddev ≈ $5,275

Now yesterday you spent $8,000 on legal fees — a real, worth-investigating spike for any normal-sized business. Classical z-score:

1
z = (8000 − 556) / 5275 ≈ 1.41   ← below the typical 2-sigma threshold, no flag

Worse: that $50k outlier doesn't go away. It permanently inflates your stddev for as long as it sits in the baseline window. The dashboard goes silent. You won't see another legal-fees flag until you spend something north of $11k in a day.

Robust statistics ignore that outlier:

1
median = $0, MAD = $0  →  flat-baseline path triggers instead

The detector falls back to the flat baseline spike signal, which catches "this category was effectively quiet, and now it isn't." Yesterday's $8k flags clean.

That's the entire point of using two statistical lenses. Different baselines fail in different ways; combining them covers more shapes.

What the detector does, mechanically

Every morning, the detector looks at yesterday's spend, broken down by category and currency, and asks: was this normal?

Spend anomaly detector flow Only confidently categorized spend enters the aggregate. The detector compares yesterday against classical and robust baselines, evaluates independent signals, filters noise, then upserts an anomaly record when something is worth review.

The entry point in packages/banking-anomaly/src/detector.ts:

1
export async function detectAnomalies(
2
  workspaceId: string,
3
  asOf: Date,
4
  options: DetectorOptions
5
): Promise<DetectedAnomaly[]> {
6
  const baseline = await loadBaseline(workspaceId, asOf, options.windowDays)
7
  const yesterday = await aggregateYesterdaySpend(
8
    workspaceId,
9
    asOf,
10
    options.minCategoryConfidence
11
  )
12
 
13
  const anomalies: DetectedAnomaly[] = []
14
  for (const [key, todaySpend] of yesterday) {
15
    const baselineSlice = baseline.get(key)
16
    if (!baselineSlice || baselineSlice.activeObservations < options.minObservations) {
17
      continue   // not enough data to trust the baseline
18
    }
19
 
20
    const classical = computeStats(baselineSlice.dailyTotals)
21
    const robust = computeRobustStats(baselineSlice.dailyTotals)
22
 
23
    const signals = evaluateSignals(todaySpend, classical, robust, options)
24
    if (signals.length === 0) continue
25
 
26
    const maxScore = Math.max(...signals.map((s) => s.score))
27
    if (todaySpend < options.minAmountMinor) continue   // amount-floor filter
28
 
29
    anomalies.push(buildAnomalyRecord(key, todaySpend, signals, maxScore))
30
  }
31
  return anomalies
32
}

computeStats returns mean and stddev; computeRobustStats returns median, MAD (scaled by 1.4826), and the 95th percentile.

The five signals

1. Legacy z-score spike

Classical: z = (yesterday − mean) / stddev. If z >= sigmaThreshold (workspace-configurable, typically 2–3), the signal fires.

1
Score: 0.7 + 0.25 × clamped(excess_multiplier)

This is the textbook approach. It's here because it's familiar, and on clean baselines it works fine.

2. Robust z-score spike

Same formula, using median and mad_stddev instead of mean and stddev.

1
Score: 0.72 + 0.25 × clamped(excess_multiplier)

The slightly higher base score reflects that this signal is harder to fool — when robust z fires, it's usually a real spike. This is the signal that catches the legal-fees example above when the classical z-score gets blinded.

3. Flat baseline spike

If the baseline has effectively zero variance (everyone spent the same amount or nothing at all), the z-score formulas blow up. We catch that explicitly: if yesterday's spend exceeds 2× the historical mean and exceeds the workspace's minAmountMinor floor, fire.

1
Score: 0.82 + 0.13 × clamped(multiple / 10)

This is what catches "this category was dormant and now it isn't."

4. Rare spend spike

If calendarMode includes calendar days and the category has never had any spend in the baseline (zero spend-days), but yesterday it did — fire.

1
Score: 0.78

This is the "first dollar in a new category" signal. It's deliberately uncommon and high-precision. Catches new types of spend that weren't budgeted.

5. Large transaction contributor

Not a standalone fire — it's a tag added to other signals. If the anomalous spend came from a single large transaction rather than many small ones, the anomaly record is annotated with large_transaction_contributor so the reviewer immediately sees whether the spike is one outsized check or a cluster of normal ones. Different remediation paths.

SignalCatchesScore base
Legacy z-score spikeClassical mean/stddev spike0.70
Robust z-score spikeMedian/MAD spike (outlier-resistant)0.72
Flat baseline spikeSpike when historical variance ≈ 00.82
Rare spend spikeFirst-ever spend in a category0.78
Large transaction contributorSpike came from one big transaction(context tag)

Calendar mode: the highest-leverage knob

A category like rent has a regular cadence — one payment a month, zero spend the other 29 days. A category like meals has spend on most weekdays and very little on weekends. The "right" baseline for these is different.

calendarMode lets the workspace pick:

ModeBest forWhat it counts
spend_daysErratic categories (legal, marketing)Only days that had spend
calendar_daysRecurring categories (rent, payroll)Every day including zeros
hybridMixed portfoliosPicks automatically per category per run

This single knob has more effect on noise than any threshold change. Wrong mode for a category and you'll either miss real anomalies or flag normal patterns endlessly.

Two filters that keep the queue clean

A signal can fire and still be filtered out before it reaches the AP team. Two filters guard the output:

  1. Amount floor (minAmountMinor). Per-workspace. A typical setting is 5000 (~$50). Anomalies below that floor never surface. This is the single most effective noise control — it eliminates the long tail of tiny anomalies that aren't worth a human's time.
  2. Category confidence (minCategoryConfidence, default 0.55). Only transactions whose categorization confidence exceeds this threshold are included in the daily aggregate. Transactions with weak categorization are excluded — we don't want to flag a "spike in software spend" that's really a $10k transaction that got coded to software because the embedding had nothing else to go on.

The second filter is why we built the categorization engine the way we did. Categorization confidence isn't decorative; it's a load-bearing input to the anomaly detector.

Severity and the score-to-action mapping

The final score is mapped to a severity label:

SeverityScore rangeDefault action
Critical≥ 0.95Top of queue + email/Slack notification
High≥ 0.85Top-of-queue card
Medium≥ 0.70Standard queue entry
Low> 0Background card

Severity drives both the UI surface and downstream automation. A critical anomaly can trigger the Spend Exception Monitor template — which posts to Slack and opens a Linear ticket — without any additional config.

Idempotent runs via fingerprinting

Each anomaly is fingerprinted by (workspace, category_slug, currency, anomaly_date, detector_version). If the detector runs twice on the same day — re-runs, retries, redeployments — the second run upserts into the existing record rather than creating a duplicate. We track firstDetectedAt and lastDetectedAt so the UI can show "we've been seeing this every day for 4 days" vs. "first detected today."

The detector version is part of the fingerprint deliberately. When we ship a new detector version, prior anomalies stay tied to the old version and new anomalies are tagged with the new one. You can re-run the new version against history without trampling the old records.

Anomaly record schema Daily category spend and baseline snapshots feed a versioned anomaly record. Contributors, review state, and notifications reference the same fingerprint, so retries update the same operational object.

What the detector doesn't do

Some honest limits:

  • No feedback loop. If a controller marks an anomaly as "expected, ignore," that signal isn't fed back into the baseline. The same pattern will flag again next month. We left the loop out for the same reason as in categorization — we'd rather ship without a learning loop than ship one that drifts.
  • No seasonality model. A category that spends $5k in winter and $20k in summer will look anomalous every June if the baseline straddles winter months. Workaround today is to shorten the baseline window for known-seasonal categories; a real seasonality model is on the roadmap.
  • sigmaThreshold is per-workspace, not per-category. A high-variance category (marketing) and a stable one (rent) share the same threshold. Edge cases get over- or under-flagged. A future version will allow per-category overrides.
  • Top contributor list is amount-ranked, not causally attributed. The anomaly record stores the top 25 transactions by amount on the spike day. That's usually what you want — but if 100 small transactions caused the spike, you only see the top 25.
  • Currencies are independent. A workspace that spends both USD and EUR gets separate anomalies per currency. We don't FX-normalize for combined detection.

Why this shape

The whole detector is built around one bias: silent is better than noisy. Every choice in the architecture — robust stats, the amount floor, the category-confidence filter, abstain-when-unsure — is there to keep the dashboard signal you can trust. A controller who looks at the anomaly queue should see things that are worth looking at, not have to learn which signals to mute.

When we get the loop and the seasonality model in, the same constraint will apply: ship them only if they raise precision without raising volume. The day the queue feels like a lottery is the day controllers stop opening it.

The detector is the third pillar of the AP brain — it sits alongside the categorization engine (which decides what a transaction is) and the reconciliation engine (which decides what matches a transaction). All three share the same instinct: deterministic where possible, conservative where not, and never confidently wrong.

The detector lives in packages/banking-anomaly. detectAnomalies is the entry; detector.ts is short and worth a read.

FAQ

Why does Cadense use robust statistics instead of a classical z-score?

A single outlier in the baseline (e.g., one $50k legal invoice) inflates standard deviation enough that subsequent legitimate spikes never flag. Robust stats use median and MAD (Median Absolute Deviation) which ignore single-point outliers, so the detector keeps working after a one-time spike.

How is the spend baseline computed?

Per (workspace, category, currency), we aggregate daily spend totals over a rolling window (default 90 days, configurable 2–365). We compute both classical (mean, stddev) and robust (median, MAD scaled by 1.4826, p95) statistics. We require at least 15 active observations before trusting the baseline.

What is calendar mode and why does it matter?

Calendar mode controls whether zero-spend days count toward the baseline. spend_days mode only counts days with spend (best for erratic categories like marketing). calendar_days counts every day including zeros (best for recurring categories like rent). hybrid picks per-category based on the historical density of non-zero days.

Will the detector flag the same anomaly twice if I re-run detection?

No. Each anomaly is fingerprinted by (workspace, category, currency, date, detector_version) and upserted, so re-runs update existing records. We track firstDetectedAt and lastDetectedAt to show how persistent an anomaly is.

How do you control noise from small transactions?

Two filters. minAmountMinor sets a per-workspace dollar floor (typical: $50) below which anomalies never surface. minCategoryConfidence (default 0.55) excludes transactions with weak categorization from the daily aggregate, so a spike in "software" caused by a poorly-categorized $10k transaction never reaches the queue.

Does the detector account for seasonality?

Not yet. A category that spends $5k in winter and $20k in summer will look anomalous every June if the baseline straddles winter months. The current workaround is to shorten the baseline window for known-seasonal categories. A real seasonality model is on the roadmap.

Why one signal can't reconcile a bank statement (and the five we use instead)
Apr 26

Why one signal can't reconcile a bank statement (and the five we use instead)

A worked example of how Cadense matches bank transactions to invoices and payments using five independent signals — amount, date, identity, history, and topology — with weekend-aware tolerance, a rules layer, and a 15-category exception taxonomy.

How we built a GL categorization engine a controller will sign off on
Apr 26

How we built a GL categorization engine a controller will sign off on

A real walkthrough of the five-stage cascade Cadense uses to suggest GL codes — deterministic rules first, embeddings as a fallback, abstention when nothing fits — and the trade-offs we made along the way.

Introducing Harness
Mar 26

Introducing Harness

A technical look at Harness, the Cadense control plane that lets AP agents read finance context, take approved actions, keep evidence, and run recurring operations safely.