FMP

FMP

Multi-Agent Corporate Fraud Detector Using FMP Financial Statements

Corporate fraud detection is most effective when treated as a screening and prioritization problem, not as a mechanism for making definitive claims. Financial statements do not reveal intent, but they do capture patterns—changes in profitability, leverage, accrual behavior, or cash flow alignment—that can signal when a company deserves closer scrutiny. Without a structured screening process, analysts often spend time reviewing companies that show no meaningful anomalies, while subtle but important red flags can be missed or evaluated inconsistently across reviews.

Using FMP as the Financial Data Foundation

In this article, we build a corporate fraud screening system using Financial Modeling Prep (FMP) as the primary financial data layer. FMP provides structured access to core financial statements—including income statements, balance sheets, and cash flow statements—along with commonly used financial ratios. These datasets make it possible to analyze fundamentals consistently across companies and reporting periods without manually parsing filings.

Data Sources Behind the Article

  • Income Statement API: Used to retrieve period-level performance data such as revenue, operating income, and net income. This data forms the foundation for profitability trends and earnings growth checks that often surface early red flags.
  • Balance Sheet Statement API: Provides point-in-time snapshots of assets, liabilities, equity, and key working-capital components. These fields are essential for analyzing leverage changes, receivables behavior, and how reported performance is being financed.
  • Cash Flow Statement API: Supplies operating, investing, and financing cash flow data across reporting periods. This dataset is critical for assessing earnings quality by comparing reported profits against actual cash generation.
  • Financial Ratios API: Offers precomputed ratios derived from statement data, such as liquidity, leverage, and efficiency metrics. These ratios help standardize comparisons across companies and reduce the need for custom calculations in the screening logic.

A Linear Multi-Agent Screening Pipeline

The system is organized as a linear multi-agent pipeline. Each agent owns a specific responsibility: retrieving statement data from FMP, computing rule-based red-flag signals, and assembling an explainable risk summary. The agents run sequentially and share a common state, keeping the workflow easy to follow and straightforward to extend. This design supports repeatable screening, clearer audit trails, and more consistent reviews across analysts and time periods.

What the Detector Is and Is Not

The goal of this detector is not to label companies as fraudulent. Instead, it aims to surface accounting and financial anomalies that often appear in forensic analysis—such as earnings growth unsupported by cash flow, rising leverage alongside margin pressure, or unusually aggressive accruals. These signals help analysts decide where to look more closely, using publicly available financial data.

What You Will Build

By the end of this article, you will have a clear blueprint for building a compact, extensible fraud screening pipeline grounded in FMP financial statements and structured using a clean multi-agent design.

System Overview: Linear Multi-Agent Fraud Screening Pipeline

The fraud detector is organized as a linear multi-agent pipeline. Each agent corresponds to a concrete analytical step and passes its output forward through a shared state. The emphasis is on determinism and clarity—no branching, no orchestration layers, and no hidden control flow.

The pipeline executes in a fixed sequence:

  1. Statement ingestion - retrieve financial statements and ratios from FMP
  2. Signal computation - compute rule-based red flags from statement data
  3. Risk synthesis - assemble an explainable summary grounded in the signals

Each stage is handled by a dedicated agent. Agents read only what they need from the shared state and append their outputs for downstream use.

Why a Linear Design Works Here

Fraud screening relies on consistent transformations of structured data, not dynamic routing. A linear design:

  • Makes assumptions explicit (what data feeds which checks),
  • Simplifies debugging and validation,
  • Supports reproducibility when screening many companies.
  • Makes large-scale execution safer by allowing each signal to be validated independently and debugged in isolation when results look unexpected.

This mirrors real forensic workflows: analysts start with statements, compute checks, then review explanations.

Shared State as the Integration Point

Agents communicate through a single state object that carries:

  • The target ticker,
  • The statement periods analyzed,
  • Computed ratios and derived metrics,
  • Red-flag indicators and explanations.

This keeps the implementation compact while allowing new checks or summaries to be added without restructuring the pipeline.

Using the Fraud Screener for Individual and Enterprise Workflows

The fraud screening pipeline described in this article can be used at different scales depending on the context. Individual analysts and researchers may run the screener on a single company or a small watchlist to quickly surface unusual relationships across financial statements and prioritize where deeper review is warranted.

In enterprise settings, the same design supports broader and more structured workflows. Audit, risk, and research teams can run the screener across large company universes, schedule recurring reviews as new filings are released, and integrate outputs into internal dashboards or review systems. As usage scales, considerations such as data coverage, request volume, and operational reliability become more important, which is where different FMP plans and pricing tiers align with individual experimentation versus ongoing, production-level screening needs.

FMP Datasets You'll Use for Fraud Signals

Fraud screening depends on how financial numbers relate to each other over time, not on any single metric in isolation. The datasets used in this pipeline are chosen to support that comparison logic. Financial Modeling Prep (FMP) provides these datasets in a structured form that fits naturally into rule-based analysis.

Income Statement: Performance and Margin Signals

The income statement captures how a company reports revenue, costs, and profitability over a period.

In this workflow, income statement data is used to:

  • Track revenue and margin trends across periods
  • Detect unusually rapid earnings growth
  • Compare operating performance against changes in balance sheet and cash flow items

Workflow mapping:
This dataset provides period-level performance figures that form the baseline for profitability and growth-related red-flag checks.

Balance Sheet: Leverage and Asset Structure

The balance sheet reflects a company's financial position at a point in time, including assets, liabilities, and equity.

Within the fraud detector, balance sheet data supports:

  • Leverage and debt trend analysis
  • Asset growth versus revenue growth comparisons
  • Working capital and receivables behavior checks

Workflow mapping:
This dataset supplies point-in-time financial structure data that helps explain how reported performance is being financed.

Cash Flow Statement: Earnings Quality Signals

Cash flow data is critical for assessing the quality of reported earnings.

In the pipeline, cash flow statements are used to:

  • Compare operating cash flow against net income
  • Flag persistent gaps between cash generation and reported profits
  • Identify reliance on financing or investing cash flows to sustain operations

Workflow mapping:
This dataset enables cash-versus-earnings checks that are commonly used in forensic accounting reviews.

Financial Ratios: Precomputed Signals

FMP also provides commonly used financial ratios derived from statement data.

These ratios are useful for:

  • Standardizing comparisons across companies
  • Reducing the need for custom ratio calculations
  • Supporting checks related to liquidity, efficiency, and leverage

Workflow mapping:
Ratios act as derived inputs that complement raw statement figures and help normalize signals across periods and peers.

Why These Datasets Are Sufficient

Together, these datasets allow the detector to observe:

  • Consistency across statements (income vs cash flow)
  • Structural shifts (balance sheet changes)
  • Trend behavior across reporting periods

They do not diagnose fraud. Instead, they provide the measurable inputs needed to screen for anomalies that warrant further review. By combining these views in a single, structured pass, the screener helps analysts prioritize which companies and periods deserve attention far more quickly than manual, statement-by-statement or metric-by-metric reviews.

Fraud-Detection Agents: Data → Signals → Risk Summary

Let's explore how the fraud screener turns raw fundamentals into a review-ready output. The system uses three agents. Each agent maps to a real step an analyst would take: collect, compute, explain.

1) StatementAgent: Pull fundamentals from FMP

This agent is responsible for retrieving and organizing the raw inputs. It fetches:

  • Income statement
  • Balance sheet
  • Cash flow statement
  • Key ratios (when useful)

It returns these as clean, time-aligned tables in the shared state.

Workflow mapping: This agent converts “ticker → structured fundamentals” so downstream logic can focus on analysis instead of ingestion.

2) RedFlagAgent: Compute rule-based forensic signals

This agent computes a set of screening signals derived from relationships across statements and trends over time. These are not accusations—they're “things to review”. Each signal is fully explainable and auditable, with a clear link between the underlying financial inputs, the computed value, and the threshold that triggered the flag.

Examples of signals you can compute from statements and ratios:

  • Earnings vs cash flow divergence: net income rising while operating cash flow lags
  • Accrual pressure (proxy): earnings growth without matching cash generation
  • Receivables growth vs revenue growth: receivables rising faster than sales (possible revenue quality concern)
  • Leverage shift: debt/liabilities rising while margins weaken
  • Margin compression + growth: growth funded by deteriorating profitability

The output of this agent is structured and machine-readable: a list of signals, each with a value, direction, and threshold decision.

Workflow mapping: This agent converts “fundamentals → red-flag signals” that are consistent, repeatable, and suitable for review and audit.

3) NarrativeAgent: Produce an explainable risk summary

This agent assembles the final report. It does two things:

  1. Computes a simple screening label (e.g., Low / Medium / Needs Review) based on how many signals fired and which ones matter most.
  2. Writes a short explanation that is grounded in the computed evidence.

A good summary output looks like:

  • Which periods were analyzed
  • Which signals triggered
  • Why they triggered (with specific numeric deltas)
  • What an analyst should check next (e.g., “review revenue recognition notes”, “inspect receivables composition”, etc.)

Workflow mapping: This agent converts “signals → explainable output” so results can be handed off, documented, and reviewed consistently as part of real fraud, audit, and internal-control workflows—not just read once and discarded.

Execution Model

The pipeline runs in this fixed order:

StatementAgent → RedFlagAgent → NarrativeAgent

Each agent reads from the shared state, appends its outputs, and passes the state forward. That keeps the system transparent and makes it easy to add or remove signals later without restructuring the whole flow.

Implementing the Core Analyzer

Below is a compact, end-to-end implementation of the fraud screener. It follows the same linear flow we defined earlier:

StatementAgent → RedFlagAgent → NarrativeAgent

By the end of this section, you'll have a working, end-to-end fraud screening pipeline that pulls fundamentals from FMP, computes transparent red-flag signals, and produces an explainable risk summary in a single pass.

The code is intentionally organized around clear boundaries. Each block does one job and returns structured outputs that the next stage can consume.

1) StatementAgent: Pull financial statements and ratios from FMP

Before retrieving any financial statements, you will need an active Financial Modeling Prep API key to authenticate requests. Once authenticated, the StatementAgent fetches four core datasets:

import requests

import pandas as pd

from dataclasses import dataclass

from typing import Dict, Any, Optional


BASE_V3 = "https://financialmodelingprep.com/api/v3"



def _get_df(url: str, params: dict) -> pd.DataFrame:

r = requests.get(url, params=params, timeout=30)

r.raise_for_status()

data = r.json()


# FMP endpoints typically return a list of period rows for statement-like data.

# Keep this defensive: if it's empty or not list-like, handle gracefully.

if isinstance(data, list):

return pd.DataFrame(data)


return pd.DataFrame([])



@dataclass

class StatementAgent:

api_key: str

period: str = "quarter" # "quarter" or "annual"

limit: int = 12 # number of periods to fetch


def run(self, state: Dict[str, Any]) -> Dict[str, Any]:

symbol = state["symbol"]


income_url = f"{BASE_V3}/income-statement/{symbol}"

bs_url = f"{BASE_V3}/balance-sheet-statement/{symbol}"

cf_url = f"{BASE_V3}/cash-flow-statement/{symbol}"

ratios_url = f"{BASE_V3}/ratios/{symbol}"


common = {

"period": self.period,

"limit": self.limit,

"apikey": self.api_key

}


income_df = _get_df(income_url, common)

bs_df = _get_df(bs_url, common)

cf_df = _get_df(cf_url, common)

ratios_df = _get_df(ratios_url, common)


# Normalize date keys for joining (most statement rows include "date")

for df in (income_df, bs_df, cf_df, ratios_df):

if not df.empty and "date" in df.columns:

df["date"] = pd.to_datetime(df["date"])


state["income_df"] = income_df

state["bs_df"] = bs_df

state["cf_df"] = cf_df

state["ratios_df"] = ratios_df


return state


Workflow mapping (what each FMP call returns):

  • Income statement returns period-level performance fields (revenue, net income, margins), used for profitability and growth check
  • Balance sheet returns point-in-time financial position fields (assets, liabilities, receivables, debt), used for leverage and working-capital checks.
  • Cash flow returns period-level cash movement (operating cash flow, etc.), used for earnings-quality checks.
  • Ratios returns computed ratios that can simplify comparisons (liquidity, profitability, efficiency).

2) RedFlagAgent: Compute screening signals (rule-based)

We'll compute a small set of common forensic-style checks. Each check is implemented as a signal with:

  • A computed value
  • A simple threshold rule
  • A short explanation grounded in the numbers

import requests

import numpy as np

import pandas as pd

from dataclasses import dataclass

from typing import Dict, Any, Optional


BASE_V3 = "https://financialmodelingprep.com/api/v3"



def _safe_col(df: pd.DataFrame, col: str) -> Optional[pd.Series]:

return df[col] if (df is not None and not df.empty and col in df.columns) else None



def _latest_two(df: pd.DataFrame) -> pd.DataFrame:

# Statements are often returned newest-first; we sort so iloc[0] is latest.

if df is None or df.empty or "date" not in df.columns:

return df

return df.sort_values("date", ascending=False).head(2).reset_index(drop=True)



def _pct_change(new, old) -> Optional[float]:

try:

if old is None or old == 0 or pd.isna(old) or pd.isna(new):

return None

return float((new - old) / abs(old))

except Exception:

return None



@dataclass

class RedFlagAgent:

# thresholds are intentionally simple and transparent; tune per your screening policy

cfo_vs_net_income_ratio_floor: float = 0.8

receivables_growth_minus_revenue_growth: float = 0.15

leverage_increase_threshold: float = 0.10

margin_drop_threshold: float = 0.05


def run(self, state: Dict[str, Any]) -> Dict[str, Any]:

income = _latest_two(state["income_df"])

bs = _latest_two(state["bs_df"])

cf = _latest_two(state["cf_df"])


signals = []


# ---- Signal 1: Operating cash flow vs net income (earnings quality proxy)

ni = _safe_col(income, "netIncome")

cfo = _safe_col(cf, "netCashProvidedByOperatingActivities")


if ni is not None and cfo is not None and len(ni) >= 1 and len(cfo) >= 1:

latest_ni = ni.iloc[0]

latest_cfo = cfo.iloc[0]

ratio = None if latest_ni in (0, None) or pd.isna(latest_ni) else float(latest_cfo / latest_ni)

fired = (ratio is not None) and (ratio < self.cfo_vs_net_income_ratio_floor)


signals.append(

{

"name": "CFO_to_NetIncome",

"value": ratio,

"fired": fired,

"why": "Flags when operating cash flow is weak relative to reported net income.",

}

)


# ---- Signal 2: Receivables growth vs revenue growth (revenue quality proxy)

rev = _safe_col(income, "revenue")

recv = _safe_col(bs, "netReceivables")


if rev is not None and recv is not None and len(rev) >= 2 and len(recv) >= 2:

rev_g = _pct_change(rev.iloc[0], rev.iloc[1])

recv_g = _pct_change(recv.iloc[0], recv.iloc[1])


diff = None if rev_g is None or recv_g is None else float(recv_g - rev_g)

fired = (diff is not None) and (diff > self.receivables_growth_minus_revenue_growth)


signals.append(

{

"name": "ReceivablesGrowth_minus_RevenueGrowth",

"value": diff,

"fired": fired,

"why": "Flags when receivables expand materially faster than revenue across periods.",

}

)


# ---- Signal 3: Leverage shift (liabilities / assets)

total_assets = _safe_col(bs, "totalAssets")

total_liab = _safe_col(bs, "totalLiabilities")


if (

total_assets is not None

and total_liab is not None

and len(total_assets) >= 2

and len(total_liab) >= 2

):

lev0 = (

None

if total_assets.iloc[0] in (0, None) or pd.isna(total_assets.iloc[0])

else float(total_liab.iloc[0] / total_assets.iloc[0])

)

lev1 = (

None

if total_assets.iloc[1] in (0, None) or pd.isna(total_assets.iloc[1])

else float(total_liab.iloc[1] / total_assets.iloc[1])

)


delta = None if lev0 is None or lev1 is None else float(lev0 - lev1)

fired = (delta is not None) and (delta > self.leverage_increase_threshold)


signals.append(

{

"name": "LeverageRatio_Delta",

"value": delta,

"fired": fired,

"why": "Flags when leverage increases notably between periods.",

}

)


# ---- Signal 4: Gross margin compression

gp = _safe_col(income, "grossProfit")

if rev is not None and gp is not None and len(rev) >= 2 and len(gp) >= 2:

gm0 = (

None

if rev.iloc[0] in (0, None) or pd.isna(rev.iloc[0])

else float(gp.iloc[0] / rev.iloc[0])

)

gm1 = (

None

if rev.iloc[1] in (0, None) or pd.isna(rev.iloc[1])

else float(gp.iloc[1] / rev.iloc[1])

)


drop = None if gm0 is None or gm1 is None else float(gm1 - gm0) # negative means drop

fired = (drop is not None) and (drop < -self.margin_drop_threshold)


signals.append(

{

"name": "GrossMargin_Change",

"value": drop,

"fired": fired,

"why": "Flags when gross margin falls materially between periods.",

}

)


state["signals"] = signals

return state



# -----------------------------

# Statement fetching utilities

# -----------------------------

def _get_df(url: str, params: dict) -> pd.DataFrame:

r = requests.get(url, params=params, timeout=30)

r.raise_for_status()

data = r.json()

# FMP endpoints typically return a list of period rows for statement-like data.

# We keep this defensive: if it's empty or not list-like, we still handle gracefully.

if isinstance(data, list):

return pd.DataFrame(data)

return pd.DataFrame([])



@dataclass

class StatementAgent:

api_key: str

period: str = "quarter" # "quarter" or "annual"

limit: int = 12 # number of periods to fetch


def run(self, state: Dict[str, Any]) -> Dict[str, Any]:

symbol = state["symbol"]


income_url = f"{BASE_V3}/income-statement/{symbol}"

bs_url = f"{BASE_V3}/balance-sheet-statement/{symbol}"

cf_url = f"{BASE_V3}/cash-flow-statement/{symbol}"

ratios_url = f"{BASE_V3}/ratios/{symbol}"


common = {"period": self.period, "limit": self.limit, "apikey": self.api_key}


income_df = _get_df(income_url, common)

bs_df = _get_df(bs_url, common)

cf_df = _get_df(cf_url, common)

ratios_df = _get_df(ratios_url, common)


# Normalize date keys for joining (most statement rows include "date")

for df in (income_df, bs_df, cf_df, ratios_df):

if not df.empty and "date" in df.columns:

df["date"] = pd.to_datetime(df["date"])


state["income_df"] = income_df

state["bs_df"] = bs_df

state["cf_df"] = cf_df

state["ratios_df"] = ratios_df

return state


What's happening (high signal-to-noise):

  • We use the latest 1-2 periods to compute simple deltas and ratios.
  • Each signal is explainable: value + threshold + why.
  • We guard against missing columns so the pipeline doesn't break if a field isn't available for a ticker/period.

3) NarrativeAgent: Turn signals into an explainable risk summary

This is a screener output. It highlights what triggered and why, without “verdict language”.

from dataclasses import dataclass

from typing import Dict, Any


@dataclass

class NarrativeAgent:

def run(self, state: Dict[str, Any]) -> Dict[str, Any]:

symbol = state["symbol"]

signals = state.get("signals", [])


fired = [s for s in signals if s.get("fired")]

score = len(fired)


if score >= 3:

label = "Needs Review"

elif score == 2:

label = "Moderate"

elif score == 1:

label = "Low"

else:

label = "No Flags Triggered"


# Build a compact explanation list

explanation = []

for s in fired:

explanation.append(

f"{s['name']} fired (value={s.get('value')}). {s.get('why')}"

)


state["risk_summary"] = {

"symbol": symbol,

"risk_label": label,

"signals_fired": score,

"details": explanation,

"note": (

"This is a screening output based on statement relationships, "

"not a fraud determination."

),

}


return state



4) End-to-end run (fixed, linear routing)

def run_fraud_screen(symbol: str, api_key: str, period: str = "quarter", limit: int = 12) -> dict:

state: Dict[str, Any] = {"symbol": symbol}


state = StatementAgent(api_key=api_key, period=period, limit=limit).run(state)

state = RedFlagAgent().run(state)

state = NarrativeAgent().run(state)


return state["risk_summary"]



Final Words: Using the Screener Responsibly + Extending It

You now have a compact fraud screening pipeline that uses Financial Modeling Prep (FMP) financial statements as the input layer and produces an explainable, review-focused output. As emphasized from the start, this system is designed to prioritize review and surface anomalies—not to label companies as fraudulent or make definitive judgments. The workflow stays consistent: retrieve fundamentals, compute transparent red-flag signals, and summarize what triggered those flags in a form that supports follow-up analysis.

This kind of detector is most useful when you treat it as a prioritization tool. It helps you quickly identify companies where the relationships across statements look unusual—such as cash flow lagging earnings, receivables expanding faster than revenue, leverage shifting materially, or margins compressing in a way that contradicts the reported growth narrative. Those patterns can justify a deeper review, but they do not establish wrongdoing on their own.

If you extend this screener next, the most practical improvements are:

  • Multi-period scoring: compute signals across multiple quarters, not just the latest comparison, to reduce sensitivity to one-off noise
  • Peer baselines: compare signals against industry medians so the output reflects sector-specific norms
  • More forensic checks: add additional statement-driven signals (inventory changes, expense ratios, unusual working-capital swings) using the same agent boundary
  • Monitoring mode: run the screener on a watchlist and track how risk labels evolve over time

The main idea remains stable: FMP provides the structured statement data, and the multi-agent design keeps your fraud screening logic modular, auditable, and easy to evolve as your signal library grows—while maintaining a clear separation between screening insight and final investigative judgment.