Real-time insider trading detection and voice alert system. Monitors SEC EDGAR Form 4 filings, scores signals with AI, and triggers automated phone calls to protect retail investors.
Use this skill when building or extending a system that:
The pipeline flows in one direction:
SEC EDGAR Form 4 → Signal Detection → Score (0-10) → Gemini Analysis → Bland AI Phone Call → Auth0 CIBA Approval → Trade Execution
| Module | File |
|---|
| Purpose |
|---|
| EDGAR Poller | src/lib/edgar.ts | Fetches and parses Form 4 XML filings from SEC EDGAR |
| Signal Scorer | src/lib/signals.ts | Multi-factor scoring: role, sale type, position %, recency, sentiment |
| AI Analyzer | src/lib/gemini.ts | Plain-English explanation via Google Gemini |
| Voice Caller | src/lib/bland.ts | Outbound calls via Bland AI with approval tool |
| Auth0 CIBA | src/lib/auth0-ciba.ts | Push-based trade approval via Guardian app |
| News Sentiment | src/lib/news.ts | Alpha Vantage sentiment scoring for watchlist tickers |
| Database | src/lib/db.ts | PostgreSQL persistence for signals and alerts |
| Portfolio | src/lib/portfolio.ts | In-memory portfolio state and trade execution |
Always map tickers to 10-digit zero-padded CIK numbers:
const CIK_MAP: Record<string, string> = {
SMCI: "0001096343",
TSLA: "0001318605",
NVDA: "0001045810",
}
To add a new ticker, look up the CIK at https://www.sec.gov/cgi-bin/browse-edgar?company=&CIK=TICKER&type=4.
SEC EDGAR enforces a 5 requests/second limit. Always add 200ms delay between requests:
await delay(200) // before each EDGAR fetch
Parse XML without external libraries using regex helpers:
function xmlTag(xml: string, tag: string): string {
const re = new RegExp(`<${tag}[^>]*>[\\s\\S]*?</${tag}>`, "i")
const m = re.exec(xml)
return m ? m[1].trim() : ""
}
Key fields to extract:
issuerTradingSymbol — tickerrptOwnerName — insider nameofficerTitle — role (CEO, CFO, etc.)transactionShares → value — share counttransactionPricePerShare → value — pricetransactionAcquiredDisposedCode → value — "D" = sell, "A" = buyCheck footnotes for "10b5-1" mentions. Scheduled sales are less suspicious:
const scheduled_10b5_1 =
footnotes.includes("10b5-1") ||
footnotes.includes("10b5\u20131") ||
planCode === "1"
Score signals 0-10 using these factors:
| Factor | Points | Condition |
|---|---|---|
| Unscheduled sale | +3 | !signal.scheduled_10b5_1 |
| C-suite insider | +2 | Role is CEO, CFO, CTO, or COO |
| Rare seller | +2 | last_transaction_months_ago > 12 |
| Large position reduction | +2 | position_reduced_pct >= 15% |
| Medium position reduction | +1 | position_reduced_pct >= 10% |
| High value sale | +1 | total_value >= $2M |
| Very negative news | +2 | sentiment_score < -0.4 |
| Somewhat negative news | +1 | sentiment_score < -0.2 |
Thresholds:
Always structure the call as:
request_approval tool on "yes"const result = await makeOutboundCall(phone, explanation, signal)
When user approves during the call:
POST /api/bland-webhook with approval data/api/execute-tradeClient-Initiated Backchannel Authentication enables push-based trade approvals:
subPOST /api/ciba-status for resultAlways set AUTH0_AUDIENCE (defaults to "911stock-api").
Two tables, auto-migrated via GET /api/migrate:
CREATE TABLE IF NOT EXISTS signals (
id TEXT PRIMARY KEY,
ticker TEXT NOT NULL,
company_name TEXT,
insider TEXT,
role TEXT,
action TEXT,
shares NUMERIC,
price_per_share NUMERIC,
total_value NUMERIC,
date TEXT,
filed_at TEXT,
scheduled_10b5_1 BOOLEAN DEFAULT FALSE,
last_transaction_months_ago NUMERIC,
position_reduced_pct NUMERIC,
score INTEGER,
explanation TEXT,
alerted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS alerts (
id TEXT PRIMARY KEY,
signal_id TEXT REFERENCES signals(id),
ticker TEXT NOT NULL,
call_id TEXT,
explanation TEXT,
approved BOOLEAN,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Use ON CONFLICT DO NOTHING when inserting signals to handle duplicate polling.
The /api/poll endpoint is the heartbeat. Hit it on an interval:
// Client-side polling every 60 seconds
setInterval(() => fetch('/api/poll', { method: 'POST' }), 60_000)
Or configure Vercel Cron in vercel.json:
{
"crons": [{ "path": "/api/poll", "schedule": "* * * * *" }]
}
The poll endpoint:
Fetch live prices from Yahoo Finance with 60-second caching:
// GET /api/stock-quote?symbol=SMCI
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${symbol}`
alerted flag — you'll double-call usersSELECT * on the signals table in production — use indexed ticker lookups| Endpoint | Method | Purpose |
|---|---|---|
/api/trigger | POST | Full pipeline: detect → analyze → call |
/api/poll | POST | Cron-safe polling: detect → dedupe → call if new |
/api/analyze | POST | Lightweight: detect → analyze (no DB, no call) |
/api/signal | GET | Fetch latest signal for display |
/api/feed | GET | Recent signals feed |
/api/stock-quote | GET | Live stock price via Yahoo Finance |
/api/call | POST | Trigger outbound Bland call manually |
/api/bland-webhook | POST | Receives Bland AI callback → initiates CIBA |
/api/ciba-status | POST | Poll Auth0 CIBA approval status |
/api/approve | POST | Manual trade approval (fallback) |
/api/execute-trade | POST | Execute portfolio trade |
/api/portfolio | GET | Current holdings and trades |
/api/migrate | GET | Run database migrations |