DEV Community

Fatih İlhan
Fatih İlhan

Posted on

I Built a Trading Signal Engine That Reads Congressional Insider Trades — Here's the Architecture

Congressional members beat the market by 12% on average. I built a system to find out why — in real time.


There's a dataset hiding in plain sight. Every time a U.S. senator or congressman buys stock, they're legally required to disclose it within 45 days. Every time a corporate CEO buys their own company's shares, that Form 4 hits the SEC within 2 business days.

Most people scroll past these filings. I built a machine to read all of them, filter out the noise, and surface only the trades worth paying attention to.

This is the architecture behind Insider Signal Engine — a personal trading signal tool I built in a few weeks using Next.js, Supabase, and Cloudflare Workers.


The Problem: Raw Insider Data Is Mostly Noise

The data is public. The problem is the signal-to-noise ratio.

In any given week, you might see 400+ congressional trade disclosures. But most of them are:

  • Sales (informationless — could be divorce, taxes, anything)
  • Filed 38 days after the trade (already priced in)
  • Tiny amounts ($1K–$15K, basically rounding errors)
  • From members with no relevant committee oversight

Running a filter stack on that data is the whole game.


Stack

Layer Choice Why
Framework Next.js 14 App Router Dashboard + API routes in one project
Database Supabase (Postgres) RLS-ready for multi-tenant SaaS later
Hosting Cloudflare Pages Edge performance, generous free tier
Cron Cloudflare Workers (scheduled) Runs the ingestion pipeline every 4 hours
Primary data Quiver Quant API ($10/mo) Congressional + insider trades, clean REST API
Secondary data Financial Modeling Prep (free tier) Earnings calendar, corporate Form 4

The ingestion pipeline runs as a Cloudflare Worker on a cron schedule, hits an internal protected API route, and writes to Supabase. The Next.js dashboard reads from Supabase and renders signals sorted by score.


Architecture

┌─────────────────────────────────────────────┐
│         CLOUDFLARE WORKER (every 4h)         │
│                                              │
│  1. Fetch congress trades from Quiver Quant  │
│  2. Fetch corporate insider trades (FMP)     │
│  3. Normalize into unified RawTrade schema   │
│  4. Run 7-filter stack                       │
│  5. Score survivors (0-100)                  │
│  6. Upsert into Supabase signals table       │
└──────────────────────┬──────────────────────┘
                       │
                       ▼
             Supabase (Postgres)
             ├── raw_trades  (append-only log)
             ├── signals     (filtered + scored)
             └── politicians (track record)
                       │
                       ▼
          Next.js Dashboard (App Router)
          /              → Signal feed
          /politicians   → Leaderboard by hit rate
          /ticker/[sym]  → Per-stock activity
          /backtest      → Historical performance
Enter fullscreen mode Exit fullscreen mode

The 7-Filter Stack

This is the core of the engine. Every trade must pass all 7 filters to become a signal. Filters are pure functions — simple to test, easy to tune.

type TradeFilter = (trade: RawTrade) => boolean;
Enter fullscreen mode Exit fullscreen mode

Filter 1: Purchases Only

const filterPurchaseOnly: TradeFilter = (trade) =>
  trade.trade_type === 'purchase';
Enter fullscreen mode Exit fullscreen mode

Sales have too many non-informative motivations — taxes, diversification, estate planning. Buys are different. Nobody buys their own stock for the wrong reasons.

(Exception: unusually large sales >$500K go to a separate "bearish watchlist" — not implemented yet.)

Filter 2: Filing Delay ≤ 7 Days

const filterFilingDelay: TradeFilter = (trade) => {
  const delay = differenceInDays(
    parseISO(trade.filing_date),
    parseISO(trade.trade_date)
  );
  return delay <= 7;
};
Enter fullscreen mode Exit fullscreen mode

Congress has 45 days to disclose. Most of them use every day of it. This filter rejects ~80% of congressional trades by design.

The fast-filers are a self-selecting group. When a senator buys $500K of defense stock and files the next day, that's a different animal from someone who files at day 44.

Filter 3: Minimum Size ≥ $50K

const filterMinSize: TradeFilter = (trade) => {
  const amount = trade.amount_high ?? trade.amount_low ?? 0;
  return amount >= 50_000;
};
Enter fullscreen mode Exit fullscreen mode

Congress reports in ranges — $1K–$15K, $15K–$50K, $100K–$250K. We use the upper bound. The $50K floor eliminates noise buys and auto-purchase plans.

Filter 4: Relevance — Committee Match or C-Suite

const COMMITTEE_SECTOR_MAP: Record<string, string[]> = {
  'Armed Services': ['defense', 'aerospace'],
  'Energy and Commerce': ['energy', 'utilities', 'healthcare'],
  'Finance': ['banks', 'fintech', 'crypto'],
  'Intelligence': ['defense', 'cybersecurity', 'tech'],
  // ...
};

const C_SUITE_TITLES = ['CEO', 'CFO', 'COO', 'CTO', 'President', 'Chairman'];
Enter fullscreen mode Exit fullscreen mode

A senator on the Senate Intelligence Committee buying a cybersecurity stock is a different signal than a backbencher doing the same. A CEO buying their own stock means something. A Director buying theirs means less.

Filter 5: Cluster Detection

Not a per-trade filter — a post-filter enrichment. After filters 1-4, we group surviving trades by ticker in a 30-day sliding window:

function detectClusters(
  filteredTrades: RawTrade[],
  windowDays: number = 30
): ClusterResult[] {
  const byTicker = groupBy(filteredTrades, 'ticker');

  for (const [ticker, trades] of Object.entries(byTicker)) {
    if (trades.length < 2) continue;
    // sliding window: find groups of 2+ trades within windowDays
  }
}
Enter fullscreen mode Exit fullscreen mode

When 3 different insiders buy the same ticker in the same month, that's a cluster. Clusters get heavily rewarded in the scoring model.

Filter 6: No Earnings Gamble (Async)

const filterNoEarningsGamble = async (trade: RawTrade): Promise<boolean> => {
  const earningsDate = await getNextEarningsDate(trade.ticker); // FMP API
  if (!earningsDate) return true;

  const diffDays = differenceInDays(
    parseISO(earningsDate),
    parseISO(trade.trade_date)
  );

  return diffDays < 0 || diffDays > 5;
};
Enter fullscreen mode Exit fullscreen mode

Insider buys 3 days before an earnings beat look brilliant in hindsight. They're also binary event gambling. This filter runs only on the survivors from 1-4, minimizing API calls to FMP's free tier (250 req/day limit).

Filter 7: Technical Check (Stub — Phase 2)

Placeholder for a 200-day SMA guardrail: reject trades where the stock is more than 20% below its long-term average. Falling knives are falling knives, even with insider buying.


The Scoring Model (0–100)

Every trade that survives the filters gets a score. The score is a sum of 6 components:

interface ScoreBreakdown {
  size_score: number;         // 0-20
  delay_score: number;        // 0-15
  cluster_score: number;      // 0-25  ← most impactful
  filer_track_record: number; // 0-20
  relevance_score: number;    // 0-10
  recency_score: number;      // 0-10
}
Enter fullscreen mode Exit fullscreen mode

The thresholds:

// SIZE (0-20)
if (amount >= 500_000)      size_score = 20;
else if (amount >= 250_000) size_score = 16;
else if (amount >= 100_000) size_score = 12;
else if (amount >= 50_000)  size_score = 8;

// FILING DELAY (0-15)
if (delayDays <= 1)      delay_score = 15;
else if (delayDays <= 3) delay_score = 12;
else if (delayDays <= 5) delay_score = 8;
else if (delayDays <= 7) delay_score = 4;

// CLUSTER (0-25) — the big one
if (cluster_strength >= 4)      cluster_score = 25;
else if (cluster_strength >= 3) cluster_score = 20;
else if (cluster_strength >= 2) cluster_score = 12;

// TRACK RECORD (0-20) — needs 10+ historical trades to activate
if (hit_rate >= 70) filer_track_record = 20;
else if (hit_rate >= 60) filer_track_record = 15;
else if (hit_rate >= 50) filer_track_record = 10;
Enter fullscreen mode Exit fullscreen mode

The score is stored alongside the full breakdown as a JSONB column in Postgres:

CREATE TABLE signals (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  ticker TEXT NOT NULL,
  filer_name TEXT NOT NULL,
  filer_type TEXT NOT NULL,         -- 'congress' | 'corporate_insider'
  score INT CHECK (score BETWEEN 0 AND 100),
  score_breakdown JSONB NOT NULL,   -- { size_score, delay_score, ... }
  filters_passed TEXT[] NOT NULL,
  cluster_id UUID,
  filing_delay_days INT NOT NULL,
  -- ...
);
Enter fullscreen mode Exit fullscreen mode

This means I can always explain why a trade scored the way it did — not just the final number.


Data Model: Three Core Tables

raw_trades — append-only ingest log. Every trade from every source lands here first, before filtering. Has a UNIQUE(source, source_id) constraint — upsert only, never blind insert.

signals — filtered and scored trades only. References raw_trades via FK. This is what the dashboard reads.

politicians — filer lookup with a computed hit_rate column (winning trades / total trades × 100, calculated in Postgres):

hit_rate NUMERIC GENERATED ALWAYS AS (
  CASE WHEN total_trades > 0
  THEN (winning_trades::NUMERIC / total_trades) * 100
  ELSE 0 END
) STORED
Enter fullscreen mode Exit fullscreen mode

The Ingestion Pipeline

The whole flow lives in a single orchestrator function:

async function runIngestionPipeline() {
  // 1. Fetch
  const [congressTrades, insiderTrades, fmpTrades] = await Promise.all([
    quiverClient.fetchCongressTrades(7),
    quiverClient.fetchInsiderTrades(7),
    fmpClient.fetchInsiderTrades(7),
  ]);

  const rawTrades = [...congressTrades, ...insiderTrades, ...fmpTrades];

  // 2. Dedup + store
  await upsertRawTrades(rawTrades); // UNIQUE constraint handles dedup

  // 3. Filter (sync first, async only on survivors)
  const { passed, clusters, rejected } = await runFilterStack(rawTrades);

  // 4. Score
  const signals = await Promise.all(
    passed.map(trade => scoreWithContext(trade, clusters))
  );

  // 5. Store
  await upsertSignals(signals);

  // 6. Expire old signals
  await markStaleSignals(30); // is_active = false after 30 days

  console.log(`Pipeline: ${rawTrades.length} ingested → ${passed.length} signals`);
}
Enter fullscreen mode Exit fullscreen mode

The cron endpoint is protected by a secret header — no auth library needed:

// /src/app/api/cron/route.ts
export async function GET(request: Request) {
  const secret = request.headers.get('x-cron-secret');
  if (secret !== process.env.CRON_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }

  await runIngestionPipeline();
  return new Response('OK');
}
Enter fullscreen mode Exit fullscreen mode

What Makes This Different From Just Using Quiver

Quiver shows you raw data. Capitol Trades shows you raw data. Unusual Whales shows you raw data — with prettier charts.

Nobody gives you a confidence score. Nobody tells you "this specific combination of factors — a cluster of 3 insiders, fast filing, large size, from a senator on the Finance Committee — has historically been worth paying attention to."

The filter stack + scoring model is the IP. The data is commodity.


Phase Roadmap

Phase 1 (done): Personal tool. Use it for 4 weeks. Track accuracy manually.

Phase 2: Backtest engine — calculate 7/30/90-day returns for every historical signal. This turns the hit rate columns from placeholders into real data. Add Telegram alerts for score ≥ 70.

Phase 3: Multi-tenant SaaS via Supabase Auth + RLS. Pricing: free tier (3 signals/day, delayed) → Pro at $15/mo (real-time feed, full history, alerts, backtest). Break-even is literally 1 paying user — infrastructure cost at this scale is basically $10/mo for the Quiver API.

Phase 4: AI-generated trade thesis per signal. Cross-reference with FDA calendar, earnings, legislation schedule. The data is already there — it just needs context.


Competitive Moat

The barrier here isn't data access — it's the model. Quiver, Unusual Whales, and Capitol Trades all show you the same filings. The moat is:

  1. The scoring model accumulates historical calibration over time (the hit_rate column)
  2. Cluster detection catches coordinated buying that raw feeds miss
  3. The filter stack eliminates noise that makes other tools feel overwhelming

Congressional trading platforms all have the same problem: too much signal, not enough filtering. Most users end up ignoring them after a few weeks because they don't know which trades to act on. A score from 0-100 solves that UX problem.


Key Technical Decisions

Why Quiver Quant over scraping? Capitol Trades has no API. You could scrape it with Apify, but Quiver gives you normalized data plus corporate insider trades, lobbying data, and WSB sentiment in one REST API. $10/mo vs. scraper maintenance is an easy call.

Why 7-day filing delay and not 45? This deliberately rejects ~80% of congressional trades. The fast-filers are a statistically distinct group. If I'm wrong about this hypothesis, the backtest data will tell me — and I can loosen the filter.

Why Cloudflare Workers for cron? Free tier covers 100K requests/day and unlimited scheduled workers. No Lambda cold starts. The entire infrastructure cost at personal-use scale is $10/mo (Quiver API only).

Why Supabase over plain Postgres? Row-Level Security means multi-tenant is a schema migration away, not an architectural rewrite. The free tier covers ~50K signals, which is years of personal use.


What's Next

Right now this is a personal tool — I use it for my own trading and I'm not ready to open it up yet. I want to run it for a few months, validate the scoring model against real returns, and see if the signals actually hold up before putting it in front of anyone else.

If the backtest data looks good, this becomes a product. The infrastructure is already designed for it — Supabase RLS for multi-tenancy, Cloudflare Pages for edge delivery, Paddle for payments. The jump from personal tool to SaaS is mostly a pricing page and an auth flow.

If you're building something similar or have thoughts on the filter logic, I'd love to hear it in the comments.


Built with Next.js 14, TypeScript strict mode, Supabase, Cloudflare Pages + Workers, and Quiver Quant API. Stack is fully open — the IP is in the filtering logic, not the framework choices.

Not financial advice. Congressional disclosure data is public record.

Top comments (0)