Multi-Agent Stock Analysis Engine with ML-powered Anomaly Detection
- System Overview
- Architecture & Data Flow
- Market Data Layer
- Risk Model
- Alpha Signals
- RAG Pipeline
- Multi-Agent System
- API Layer & SSE Streaming
- Frontend
- End-to-End Walkthrough
- Quick Start
- API Reference
- Performance Benchmarks
- Project Structure
- Tech Stack
StockAudit AI is a full-stack platform that takes a stock ticker or company name and produces a multi-faceted analysis report. It computes 9 financial features from 1 year of daily OHLCV data, scores the stock through two independent risk models (XGBoost binary probability + percentile deviation from 52-week history), detects institutional whale activity and retail concentration, retrieves relevant news via TF-IDF RAG, and feeds everything to three sequential LLM agents (CrewAI + Groq) that output structured JSON. Results stream to a React dashboard via Server-Sent Events.
The system was designed for zero-cost operation: all data comes from the free yfinance API, all ML is CPU-based (XGBoost + scikit-learn), and the LLM calls use Groq's free tier.
User types "apple" in search bar
|
v
EventSource connects to GET /api/analyze/stream/{symbol}
|
v
Backend resolves "apple" -> "AAPL" via yfinance Search
|
v
Backend fetches 1 year of daily OHLCV from yfinance
|
v
Backend computes 9 engineered features on the full dataframe
|
v
Backend computes whale signals (rolling regression), exhaustion, retail concentration
|
v
Backend fetches macro context: VIX (^VIX) and 10Y yield (^TNX)
|
v
Backend runs XGBoost predict_proba + percentile deviation scoring + SHAP values
|
v
Backend pre-populates TF-IDF RAG store with 10-15 news articles + business summary
|
v
Backend launches 3 sequential CrewAI agents (each with Research Company tool)
|
v
Results progressively stream to browser via SSE (10 events total)
The streaming endpoint emits exactly these 10 events in order:
1. market_data -> MarketSnapshot, macro (VIX/TNX), signals
2. risk_assessment -> RiskGauge (dual scores, SHAP, factors, disagreement)
3. chart_data -> PriceChart (100-day OHLCV)
4. alpha_signals -> AlphaSignals (whale, retail, exhaustion)
5. rag_status -> RAG badge (N articles indexed)
6. agent_start -> "Market Analyst" status = running
7. agent_complete -> MarketAnalysisReport JSON
8. agent_start -> "Risk & Compliance" status = running
9. agent_complete -> RiskReport JSON
10. agent_start -> "Report Summary" status = running
11. agent_complete -> FinalReport JSON
12. complete -> stream done
Events 6-11 repeat for each of the 3 agents (2 events each = 6 events, plus the 5 pre-agent events = 11 total SSE messages, but agent_start+agent_complete come in pairs so there are 3 pairs = 6 + 5 pre-agent = 11 data events, plus the complete event = 12 lines total, usually referred to as 10 logical stages).
main.py: event_stream()
1. get_latest_features(symbol) -> (features_df, market_data_dict, full_df, resolved_symbol)
market_data.py:
resolve_symbol(query) -> searches yfinance, returns ticker
fetch_stock_data(ticker) -> yf.Ticker.history(period="1y")
compute_features(df) -> 9 features + whale signals + exhaustion
fetch_macro_context() -> yf.Ticker("^VIX") + yf.Ticker("^TNX")
returns dict with OHLCV + features + macro + whale + resolved_symbol
2. predict_risk(features_df, full_df) -> risk_data dict
risk_model.py:
XGBoost: model.predict_proba() -> P(anomaly|X)
Percentile: for each feature, compute F_i(current) vs 52wk empirical CDF
SHAP: booster.predict(dmat, pred_contribs=True) -> Shapley values
model_disagreement: flag when severity levels differ by 2+
3. chart_data = full_df.tail(100) -> [{date, open, high, low, close, volume}]
4. alpha_signals = get_retail_concentration() + whale_signals from market_data
5. rag.build_store(symbol) -> fetches news, chunks, TF-IDF vectorizes, caches
6. stream_analysis(symbol, market_data, risk_data, queue) -> runs 3 Crews
agents.py:
Crew 1: Market Analyst (ANALYSIS) -> JSON
Crew 2: Risk & Compliance (RISK) -> JSON
Crew 3: Report Summary (REPORT) -> JSON
Each: emits agent_start -> agent runs -> agent_complete via Queue
File: backend/market_data.py
resolve_symbol(query: str) -> str uses yfinance.Search(query) to map company names to tickers. It iterates search.quotes looking for quoteType == "EQUITY" with a valid symbol. If no match, it returns the uppercase query as-is (so TSLA stays TSLA, AAPL stays AAPL). This enables typing "apple" instead of "AAPL".
fetch_stock_data(symbol, period="3mo") calls yf.Ticker(symbol).history(period="1y") to get 1 year of daily OHLCV data (Open, High, Low, Close, Volume, Dividends, Stock Splits). If the initial fetch fails, it retries with the resolved symbol.
compute_features(df: pd.DataFrame) -> pd.DataFrame takes the raw OHLCV DataFrame and computes these 9 features:
daily_return(t) = Close(t) / Close(t-1) - 1 # daily pct change
volume_ma20(t) = SMA(Volume, 20) # 20-day avg volume
volume_change(t) = (Volume(t) - volume_ma20(t)) / volume_ma20() # volume vs 20d avg
volatility(t) = STD(daily_return, 20) # 20-day rolling vol
ma20(t) = SMA(Close, 20) # 20-day moving avg
ma50(t) = SMA(Close, 50) # 50-day moving avg
gap_from_ma20(t) = (Close(t) - ma20(t)) / ma20(t) # pct distance from MA20
gap_from_ma50(t) = (Close(t) - ma50(t)) / ma50(t) # pct distance from MA50
spread_pct(t) = (High(t) - Low(t)) / Close(t) # daily range %
RSI(14):
delta = Close(t) - Close(t-1)
gain(t) = max(delta, 0)
loss(t) = -min(delta, 0)
avg_gain = SMA(gain, 14)
avg_loss = SMA(loss, 14)
RS = avg_gain / avg_loss
RSI = 100 - (100 / (1 + RS))
price_position(t) = (Close(t) - low_20d(t)) / (high_20d(t) - low_20d(t))
# where high_20d = MAX(High, 20), low_20d = MIN(Low, 20)
volume_price_ratio(t) = Volume(t) / (Close(t) * SMA(Volume, 20))
All NaN rows are dropped after computation.
fetch_macro_context() -> dict fetches the CBOE Volatility Index (^VIX) and 10-Year Treasury Yield (^TNX) via yfinance. Results are cached in an in-memory dict with a 1-hour TTL (_MACRO_CACHE). Returns {"vix": 16.59, "tnx": 4.56} or None if fetch fails.
get_latest_features() returns a tuple:
features: single-row DataFrame with the 9 feature columns (for model inference)market_data_dict: full dict with all OHLCV values, all 9 features, date, whale_signals, macro context, resolved_symbolfull_df: complete DataFrame with computed features (for percentile deviation calculation)resolved_symbol: the canonical ticker (e.g., "AAPL" for "apple")
The market_data_dict is what gets serialized into the market_data SSE event.
File: backend/risk_model.py
The risk model generates two independent scores and explanatory metadata. The model is trained by backend/train_model.py.
P(anomaly | X) = sigmoid( sum_t f_t(X) )
Where f_t is the t-th decision tree in an ensemble of 200 trees (max_depth=6, learning_rate=0.1).
Training (train_model.py):
- Fetches 6 months of data for 16 stocks (AAPL, MSFT, GOOGL, AMZN, TSLA, META, NVDA, JPM, V, JNJ, RELIANCE.NS, TCS.NS, HDFCBANK.NS, INFY.NS, ICICIBANK.NS, SBIN.NS)
- Computes features, then labels each row: the top 7% by multi-feature z-score are labeled "anomaly" (class 1), the rest "normal" (class 0)
- Augments with 10,000 synthetic samples where anomalies have extreme daily_returns (+/-8-25%) and volume_changes (2-10x)
- Trains XGBClassifier with
scale_pos_weightto balance the 93:7 class split - Achieves ~94% accuracy on holdout test set
Inference: model.predict_proba(X)[:, 1] returns the probability of the "anomaly" class. For most stocks on most days, this is near 0 because true multi-feature anomalies are rare in the training distribution.
R = ( sum_i w_i * D_i ) / ( sum_i w_i )
Where:
D_i = 2 * | F_i(x_current) - 0.5 | (deviation from median, 0 to 1)
F_i(x) = rank_t(x) / T (empirical CDF over 52-week window)
w_i = feature_importance_i (from XGBoost model)
For each of the 9 features, the current value is ranked against all historical values in the 52-week window. The percentile F_i(x) ranges from 0 (feature at all-time low) to 1 (feature at all-time high). The deviation D_i is 0 when the feature is at its 52-week median and approaches 1 as it nears the extremes (0th or 100th percentile).
The final score is a weighted average across all 9 features using XGBoost's feature_importances_ as weights. This score:
- Is never zero (even normal days have some deviation from the median)
- Varies meaningfully between stocks
- Has a clear interpretation ("52-week percentile rank")
- Ranges from 0% (all features at median) to 100% (all features at extremes)
Per-prediction Shapley values are computed using XGBoost's native support:
booster = model.get_booster()
dmat = xgb.DMatrix(current, feature_names=FEATURE_COLUMNS)
contribs = booster.predict(dmat, pred_contribs=True)The output matrix has shape (n_samples, n_features + 1) where:
- Columns 0..n-1 are the SHAP contribution for each feature
- Column n is the bias term (expected base margin)
The sum property: sum(phi_i) + bias = raw_margin_score (before sigmoid transformation to probability).
Each SHAP value represents the marginal contribution of that feature to pushing the prediction away from the average. Positive SHAP = feature pushes toward anomaly; negative = pushes toward normal.
The two scores are independently mapped to severity levels:
XGBoost: < 0.3 -> LOW | 0.3-0.6 -> MEDIUM | >= 0.6 -> HIGH
Percentile: < 0.2 -> LOW | 0.2-0.4 -> MEDIUM | >= 0.4 -> HIGH
If the levels differ by 2 or more (e.g., XGBoost says LOW but percentile says HIGH), model_disagreement: true is set. This is common because the two methods measure fundamentally different things:
- XGBoost: "is this a statistical anomaly compared to what the model learned?"
- Percentile: "is this extreme relative to the stock's own recent range?"
| Percentile Range | Label |
|---|---|
| >= 95 | extreme |
| 80-94.9 | elevated |
| 60-79.9 | above avg |
| 40-59.9 | near median |
| 20-39.9 | below avg |
| < 20 | depressed |
{
"risk_score": 0.4643,
"xgb_score": 0.0006,
"xgb_margin": -3.2914,
"severity": "HIGH",
"model_disagreement": true,
"prediction": 0,
"scores": {
"xgboost": 0.0006,
"xgboost_severity": "LOW",
"percentile_deviation": 0.4643,
"percentile_severity": "HIGH"
},
"shap_values": [
{"feature": "rsi_14", "shap": 0.0083},
{"feature": "volume_change", "shap": -0.0012}
],
"contributing_factors": [
{
"feature": "rsi_14",
"importance": 0.1729,
"value": 91.1026,
"percentile": 99.5,
"direction": "extreme"
}
]
}File: backend/alphas.py
Three independent signals computed from the same OHLCV data:
Uses rolling regression to detect anomalous volume that doesn't correspond to price movement:
# For each 60-day window:
# Independent variable: |daily_return| (absolute price change)
# Dependent variable: Volume
# Residual = actual_volume - predicted_volume_from_regression
#
# z_residual = (residual - mean(residuals_60d)) / std(residuals_60d)Interpretation:
z_residual > 1.5AND price change is small (|return| < 0.02): ACCUMULATION — large volume with minimal price impact suggests informed buyingz_residual < -1.5AND price trending down: DISTRIBUTION — large volume with downward drift suggests informed selling- Otherwise: NONE
The z-score uses a rolling 60-window for local normalization.
Detects when a price rally is losing steam:
# Conditions for exhaustion:
# 1. RSI(14) > 70 (overbought)
# 2. recent_return (5d avg) > 2 * volatility (20d) (strong recent move)
# 3. volume_residual < 0 (volume lower than expected for this price move)Flagged as exhaustion_signal: true when all three conditions are met. The frontend shows "EXHAUSTED" vs "NORMAL".
Uses yfinance's institutional holdings data:
retail_float_pct = 100 - institutional_pct
# Where institutional_pct = ticker.institutional_holders (sum of heldPercent)If retail_float_pct > 50%, the stock is flagged as high retail concentration. The hypothesis: high retail ownership makes stocks prone to emotional trading and mean-reversion.
File: backend/rag.py
Zero new dependencies — uses sklearn.feature_extraction.text.TfidfVectorizer and sklearn.metrics.pairwise.cosine_similarity which are already installed.
-
yfinance news:
yf.Ticker(symbol).newsreturns up to 15 recent articles from Yahoo Finance. Each has: title, summary, publisher, providerPublishTime, canonicalUrl. The system extracts title + summary (full articles are not available through this API, only previews). -
Business summary:
yf.Ticker(symbol).info.longBusinessSummaryreturns the company's business description (typically 1-3 paragraphs).
- Business summary: split on periods, keep sentences > 60 characters as separate documents
- News articles: concatenate as
"Title: {title}\nSummary: {summary}"— each article is one document - Total: typically 10-20 documents per symbol
vectorizer = TfidfVectorizer(stop_words="english", max_features=2000)
tfidf_matrix = vectorizer.fit_transform(documents)The vectorizer and matrix are stored in an in-memory cache keyed by symbol.
When an agent calls the Research Company tool with a query:
query_vec = vectorizer.transform([query])
similarities = cosine_similarity(query_vec, tfidf_matrix)[0]
top_indices = argsort(similarities)[-3:][::-1] # top 3
# Filter out similarities < 0.05Results are returned as a text block prefixed with [NEWS] or [BUSINESS].
CACHE: dict[str, dict] = {}
CACHE_TTL = 3600 # 1 hourThe store is pre-populated during the SSE stream (before agents start) so agent tool calls are fast in-memory lookups. A status() function returns {"indexed": True, "article_count": N} for the frontend badge.
File: backend/agents.py
Three agents run sequentially (not in parallel). Each agent is its own Crew with a single agent + task. Agents share context through the stream_analysis() function which passes market_data_dict and risk_data to each subsequent agent.
Role: Performs technical analysis.
Schema output:
{
"summary": "string (1-2 sentences with numbers)",
"trend": {"direction": "up|down|sideways", "ma20_gap_pct": "float", "ma50_gap_pct": "float"},
"levels": {"support": "float", "resistance": "float"},
"rsi": {"value": "float", "signal": "overbought|oversold|neutral"},
"volume": {"shares": "int", "vs_avg_pct": "float"},
"outlook": {"direction": "string", "key_level": "float"}
}Role: Interprets the dual risk scores, identifies top drivers using SHAP values and percentile extremes.
Schema output:
{
"score": {"value": "float", "level": "LOW|MEDIUM|HIGH"},
"top_driver": {"feature": "string", "importance": "float", "value": "float", "signal": "string"},
"flag": "string (red flag or 'None')",
"watch": "string (what to monitor)"
}Role: Synthesizes all agent outputs into a final verdict.
Schema output:
{
"verdict": "BUY|SELL|HOLD",
"confidence": "float (0-100)",
"summary": "string (1-2 sentences with numbers)",
"metrics": [{"measure": "string", "value": "string", "signal": "string"}],
"takeaways": ["string", "string", "string"]
}- Maximum 4-6 sentences per agent
- No hedging language ("suggests", "may", "could")
- Every sentence contains a number
- All floats pre-formatted before injection:
$308.82not$308.82000732421875,1.3%not0.0125578 - All prompt data presented as explicit labeled lines:
"RSI(14): 91.1","MA20: $289.22" - Agent receives the pre-populated RAG store via a
Research Companytool that accepts queries like "recent events AAPL" or "risks AAPL"
Since LLMs sometimes wrap JSON in markdown code fences or add trailing text, the _extract_json() function handles parsing:
def _extract_json(text: str) -> dict:
# Strip ```json and ``` fences
# Find first { and last }
# Try JSON.parse
# If fails, try _extract_all which uses regex for partial extraction
# Last resort: {"verdict": "HOLD", "summary": "...", _fallback: true}CrewAI 1.14.5 has a bug where it passes cache parameters to Groq's API, which unsupported fields. Fixed by patching at module load:
import crewai.llms.cache as _cache
_cache.mark_cache_breakpoint = lambda msg: msgFile: backend/main.py
Uses asyncio.Queue + StreamingResponse:
async def event_stream():
queue = asyncio.Queue()
# 1. Fetch market data, features, risk score, chart data, alpha signals, RAG status
# Yield each as data: {"type": "...", "data": {...}}
# 2. Launch agents as background task: stream_analysis(..., queue)
# 3. Read from queue: agent_start -> agent_complete pairs
# 4. agent_task emits events via queue.put()
# 5. On complete or error, break
return StreamingResponse(event_stream(), media_type="text/event-stream")Key design decisions:
- Queue-based: Agents put events on an
asyncio.Queue, the main coroutine reads and yields. This separates agent execution from SSE framing. - Sequential agents: Each agent runs as its own Crew so events can be emitted between agents. A single multi-agent Crew would block until all agents finish.
- Background task cancellation: On client disconnect (GeneratorExit), the agent task is cancelled to avoid wasted compute.
- No WebSocket dependency: SSE works with standard HTTP, EventSource handles reconnection, no special proxy config needed.
All origins allowed (allow_origins=["*"]) — suitable for development.
stream_analysis() in agents.py receives the market_data_dict and risk_data dict. Each agent gets the full context, but the prompts selectively extract relevant fields. The RAG store is pre-populated before the first agent starts.
- Market data fetch errors return 404 (no data for symbol)
- Risk model errors return partial data (score=0, no factors)
- Agent errors are caught per-agent and replaced with error messages (remaining agents still run)
- Background tasks are cancelled in
finallyblocks
Files: frontend/src/App.jsx and components in frontend/src/components/
The dashboard uses a single EventSource connection that progressively accumulates state:
const es = new EventSource(`/api/analyze/stream/${symbol}`)
es.onmessage = (event) => {
const d = JSON.parse(event.data)
switch (d.type) {
case 'market_data': setMarketData(d.data); break
case 'risk_assessment': setRiskAssessment(d.data); break
case 'chart_data': setChartData(d.data); break
case 'alpha_signals': setAlphaSignals(d.data); break
case 'rag_status': setRagInfo(d.data); break
case 'agent_start': setAgentStatus(prev => ({...prev, [d.role]: 'running'})); break
case 'agent_complete':
setAgentStatus(prev => ({...prev, [d.role]: 'complete'}))
setAgentReports(prev => [...prev, {role, tag, output}])
break
case 'complete': es.close(); setStatus('complete'); break
case 'error': es.close(); setError(d.message); break
}
}Each event type sets its corresponding state. The dashboard renders progressively — RiskGauge and MarketSnapshot appear as soon as market_data and risk_assessment arrive, without waiting for agents.
App.jsx
+-- StockSearch (symbol input + analyze button)
+-- StatusBar (loading, agent progress, RAG indicator)
+-- Dashboard (2-column grid)
| +-- RiskGauge (dual scores, SHAP, disagreement banner)
| +-- MarketSnapshot (VIX, 10Y, OHLCV, RSI, volume)
| +-- AlphaSignals (whale, exhaustion, retail concentration)
| +-- PriceChart (100-day Recharts area chart)
+-- Agent Reports
| +-- AuditReport x3 (collapsible, routes to structured widgets)
| +-- MarketAnalysisReport (trend/levels/RSI grid)
| +-- RiskReport (score/driver/flag panels)
| +-- FinalReport (verdict badge + metrics + takeaways)
+-- Article Modal (click RAG badge -> shows news articles)
RiskGauge.jsx: Two MiniGauge components side-by-side showing XGBoost and percentile deviation. Each has an info icon with the full formula tooltip. SHAP values shown in contributing factors as (w=0.1729, SHAP=+0.0083). Model disagreement banner renders when model_disagreement: true.
MarketSnapshot.jsx: 10-metric grid. Shows trading date, VIX, 10Y Yield, Close, Open, High, Low, Volume, Return, Volatility, RSI. VIX > 25 colored red, > 18 colored orange.
AlphaSignals.jsx: Three cards: Whale Detection (signal + z-score), Momentum Exhaustion (status), Retail Concentration (retail % + institutional %).
AuditReport.jsx: Detects JSON vs markdown output via tryParseJSON(). Routes to structured components for JSON, falls back to dangerouslySetInnerHTML for raw markdown.
Clicking the "RAG active (N articles)" badge triggers a fetch to GET /api/rag/articles/{symbol}. Results display in a modal overlay with article title, publisher, and content. Business summary displayed separately.
Light ivory/white theme (--bg: #f4f2ed, --surface: #ffffff, --text: #1a1a28, --accent: #50509e). Clean professional look with no emojis. All info tooltips use a CSS-styled (i) icon.
Tracing "apple" through the entire system:
- Browser: User types "apple" in StockSearch, clicks Analyze
- EventSource connects:
GET /api/analyze/stream/apple - Backend resolves:
resolve_symbol("apple")-> yfinance Search returns{"symbol": "AAPL", "quoteType": "EQUITY", ...}-> returns"AAPL" - Fetch 1y OHLCV:
yf.Ticker("AAPL").history(period="1y")-> 252 rows of daily data - Compute features: 9 features + whale regression + exhaustion signal on the full DataFrame
- Fetch macro:
yf.Ticker("^VIX")+yf.Ticker("^TNX")->{"vix": 16.59, "tnx": 4.56} - Risk scoring:
- XGBoost: features ->
predict_proba->0.0006(LOW — model sees no anomaly) - Percentile: for each of 9 features, rank current vs 52wk history -> RSI at 99.5th percentile, MA50 gap at 99.5th -> weighted average
0.4643(HIGH) - SHAP:
booster.predict(dmat, pred_contribs=True)-> RSI contributes+0.0083to anomaly score, volatility contributes-0.0012 - Disagreement: XGBoost LOW vs percentile HIGH ->
model_disagreement: true
- XGBoost: features ->
- Chart data: Last 100 rows ->
[{date, open, high, low, close, volume}] - Alpha signals: Whale z = -0.82 (NONE), retail = 34.2% institutional (normal)
- RAG build: Fetch 10-15 AAPL news articles + business summary -> TF-IDF vectorize -> cache
- SSE yield market_data -> browser renders MarketSnapshot + RiskGauge
- SSE yield risk_assessment -> browser renders dual scores, factors, SHAP, disagreement banner
- SSE yield chart_data -> browser renders PriceChart
- SSE yield alpha_signals -> browser renders AlphaSignals
- SSE yield rag_status -> browser renders "RAG active (10 articles)" badge
- Agent 1 (Market Analyst): Receives market data + risk data + RAG tool. Calls
Research Companywith "recent events AAPL". Gets news about Apple's latest earnings, product launches. Outputs structured JSON with trend="up", RSI=91.1 "overbought", support/resistance levels. - SSE yield agent_start + agent_complete for Market Analyst -> browser renders MarketAnalysisReport
- Agent 2 (Risk & Compliance): Same context + Agent 1's output. Calls
Research Companywith "risks AAPL". Outputs JSON with score, top driver, flag. - Agent 3 (Report Summary): All previous context. Outputs JSON with verdict, confidence, metrics, takeaways.
- SSE yield complete -> browser sets status="complete", status bar hides
- User clicks RAG badge ->
fetch("/api/rag/articles/AAPL")-> modal shows 10 articles
- Python 3.14+
- Node.js 18+
uvpackage manager (or pip)
uv venv
source .venv/bin/activate
uv pip install -r requirements.txt
cd frontend && npm install && cd ..echo "GROQ_API_KEY=gsk_your_key_here" > .envGet a free API key at https://console.groq.com
uv run python backend/train_model.pyTerminal 1 - Backend API:
uv run uvicorn backend.main:app --host 0.0.0.0 --port 8000Terminal 2 - Frontend dashboard:
cd frontend && npx vite --port 5173Open http://localhost:5173 and enter a symbol or company name (e.g., apple, TSLA, RELIANCE.NS).
Returns Server-Sent Events. Stream ends with {"type": "complete"}.
| Event | When | Contents |
|---|---|---|
market_data |
~0.5-1s | OHLCV, features, macro (VIX/TNX), whale signals, resolved symbol |
risk_assessment |
~1ms after | Dual scores, SHAP, factors, model disagreement |
chart_data |
immediate | 100-day OHLCV array |
alpha_signals |
immediate | Whale z-score/signal, retail %, exhaustion flag |
rag_status |
~0.1s after | Article count, indexed status |
agent_start |
per agent | Role name + tag |
agent_complete |
per agent (~2-4s) | Role + tag + structured JSON output |
complete |
end | Signals termination |
Returns full JSON after all agents complete. Same structure as accumulated SSE payload.
Returns cached RAG store articles:
{
"symbol": "AAPL",
"articles": [
{"type": "news", "title": "...", "publisher": "...", "content": "..."},
{"type": "business", "content": "..."}
]
}Returns whale signals, retail concentration, and market data for a single stock.
Scans a 16-stock universe and returns results ranked by alpha score (composite of whale + retail + exhaustion, each contributing 1-2 points, max 4).
Returns {"status": "ok"}.
| Parameter | Value |
|---|---|
| LLM | groq/llama-3.3-70b-versatile (Groq Cloud free tier) |
| ML Model | XGBoost + Percentile Deviation + SHAP |
| Backend | FastAPI + Uvicorn (single worker) |
| Python | 3.14.3 |
| Hardware | Apple Silicon (M-series) |
| Data | yfinance (no cache warm) |
| Symbol | Time | XGBoost | Percentile | Severity |
|---|---|---|---|---|
| AAPL | 8.9s | 0.06% (LOW) | 46.43% (HIGH) | HIGH |
| GOOGL | 7.8s | 0.02% (LOW) | 61.10% (HIGH) | HIGH |
| TSLA | 11.2s | 0.00% (LOW) | 42.10% (HIGH) | HIGH |
Avg: 9.3s | Median: 8.9s | Min: 7.8s | Max: 11.2s
| Stage | Time | % of total |
|---|---|---|
| yfinance data fetch + feature engineering | ~0.5-1s | ~8% |
| XGBoost + percentile + SHAP inference | <1ms | ~0% |
| RAG build (TF-IDF vectorize) | ~50-100ms | ~1% |
| Agent 1 - Market Analyst (LLM) | ~2-4s | ~30% |
| Agent 2 - Risk & Compliance (LLM) | ~2-4s | ~30% |
| Agent 3 - Report Summary (LLM) | ~2-4s | ~30% |
| Metric | Value |
|---|---|
| Total wall time | 5.1s |
| AVG per request | 2.0s |
| Throughput | 47.1 req/min |
| Metric | Value |
|---|---|
| Total time | 25.8s |
| AVG | 2.6s |
| MEDIAN | 1.4s |
| Throughput | 23.3 req/min |
-
Model divergence: Percentile deviation consistently scores HIGH (40-70%) while XGBoost scores near 0%. The binary anomaly classifier sees rare events; the percentile score sees current market extremes vs 52-week history. They measure different things.
-
Cold vs warm: First request to a symbol takes 8-11s (yfinance uncached). Subsequent requests (with yfinance data cached by pandas-datareader) take 1-4s.
-
Concurrency scales: 4 concurrent requests complete in ~5s wall time vs ~37s sequential (8x improvement). FastAPI async handles this without extra workers.
-
LLM is the bottleneck: ~85-90% of latency comes from 3 sequential Groq API calls. ML inference (XGBoost + percentile + SHAP combined) is sub-millisecond.
-
RAG is negligible: TF-IDF vectorization takes ~50ms for 10-15 documents. Agent tool calls (cosine similarity lookup) are ~2ms each.
-
Rate limits: Free Groq tier limits to ~5-6 requests before RateLimitError. Paid tier removes this.
.
├── backend/
│ ├── main.py # FastAPI entry, SSE streaming, all REST endpoints
│ ├── agents.py # CrewAI agents, prompts, JSON schemas, extraction
│ ├── market_data.py # yfinance fetch, 9 features, macro, name resolution
│ ├── risk_model.py # XGBoost + percentile + SHAP + disagreement
│ ├── train_model.py # Model training (16 stocks + synthetic)
│ ├── alphas.py # Whale regression, exhaustion, retail concentration
│ ├── rag.py # TF-IDF RAG: fetch, chunk, vectorize, search, cache
│ ├── stock_risk_model.pkl # Trained XGBoost model
│ └── __pycache__/
├── frontend/
│ ├── src/
│ │ ├── index.jsx # React entry
│ │ ├── App.jsx # EventSource main, progressive state, article modal
│ │ ├── App.css # Light ivory/white theme
│ │ └── components/
│ │ ├── StockSearch.jsx # Input + analyze button
│ │ ├── RiskGauge.jsx # Dual gauges, SHAP, disagreement banner
│ │ ├── MarketSnapshot.jsx # VIX, 10Y, OHLCV, RSI, volume grid
│ │ ├── PriceChart.jsx # Recharts area chart
│ │ ├── AlphaSignals.jsx # Whale + retail cards
│ │ ├── AuditReport.jsx # JSON/markdown detection, routing
│ │ ├── MarketAnalysisReport.jsx # Trend/levels/RSI grid
│ │ ├── RiskReport.jsx # Score/driver/flag panels
│ │ └── FinalReport.jsx # Verdict, metrics table, takeaways
│ ├── vite.config.js # /api proxy to :8000
│ └── package.json
├── fraud-pipeline/ # Preserved original fraud detection
├── archive/ # Preserved original entry points
├── requirements.txt # Pinned Python dependencies
└── .env # GROQ_API_KEY (not committed)
| Category | Technology |
|---|---|
| Orchestration | CrewAI 1.14.5 |
| LLM Inference | Llama 3.3 70B via Groq Cloud |
| ML Model | XGBoost 3.2 (scikit-learn 1.8) |
| Backend | FastAPI 0.136 + Uvicorn 0.48 |
| Frontend | React 18 + Vite 5 + Recharts 2 |
| Data | yfinance 1.4, Pandas 3.0, NumPy 2.4 |
| Language | Python 3.14 |
MIT