FMP
Dec 30, 2025
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.
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.
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.
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.
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.
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:
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.
Fraud screening relies on consistent transformations of structured data, not dynamic routing. A linear design:
This mirrors real forensic workflows: analysts start with statements, compute checks, then review explanations.
Agents communicate through a single state object that carries:
This keeps the implementation compact while allowing new checks or summaries to be added without restructuring the pipeline.
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.
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.
The income statement captures how a company reports revenue, costs, and profitability over a period.
In this workflow, income statement data is used to:
Workflow mapping:
This dataset provides period-level performance figures that form the baseline for profitability and growth-related red-flag checks.
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:
Workflow mapping:
This dataset supplies point-in-time financial structure data that helps explain how reported performance is being financed.
Cash flow data is critical for assessing the quality of reported earnings.
In the pipeline, cash flow statements are used to:
Workflow mapping:
This dataset enables cash-versus-earnings checks that are commonly used in forensic accounting reviews.
FMP also provides commonly used financial ratios derived from statement data.
These ratios are useful for:
Workflow mapping:
Ratios act as derived inputs that complement raw statement figures and help normalize signals across periods and peers.
Together, these datasets allow the detector to observe:
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.
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.
This agent is responsible for retrieving and organizing the raw inputs. It fetches:
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.
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:
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.
This agent assembles the final report. It does two things:
A good summary output looks like:
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.
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.
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.
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):
We'll compute a small set of common forensic-style checks. Each check is implemented as a signal with:
|
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):
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 |
|
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"] |
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:
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.
Introduction In corporate finance, assessing how effectively a company utilizes its capital is crucial. Two key metri...
Bank of America analysts reiterated a bullish outlook on data center and artificial intelligence capital expenditures fo...
Pinduoduo Inc., listed on the NASDAQ as PDD, is a prominent e-commerce platform in China, also operating internationally...