stock-analysis-api / ARCHITECTURE.md
vjeai's picture
fix: GET / root route for HF Spaces proxy health check
6071e25
|
Raw
History Blame Contribute Delete
16.2 kB

Architecture & Component Reference

A precise map of every component, how it is called, what it returns, and how to replace it in isolation. Read this before making changes.


1. System Overview

Browser / Vercel (xenex-ai.io)
        β”‚
        β”œβ”€β”€ GET /quick/AAPL  ──────────────────────────────────────────────────────┐
        β”‚   (via Next.js rewrite β†’ HF Space)                                       β”‚
        β”‚                                                                          β–Ό
        β”‚                                                              HF Space (FastAPI)
        β”‚                                                                  backend/main.py
        β”‚                                                                          β”‚
        └── EventSource /stream/AAPL  ──────────────────────────────────────────► β”‚
            (direct browser β†’ HF, bypasses Vercel 30s timeout)                    β”‚
                                                                                   β”‚
                                                      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                      β”‚
                                              orchestrator.py
                                                      β”‚
                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                    β”‚                 β”‚                  β”‚
                                 Phase 1           Phase 2           Phase 3
                               DataAgent     Tech + Fund          Report + Risk
                              (sequential)     (parallel)         (sequential)

2. Entry Points

GET /quick/{ticker} β€” Fast Signal (no LLM)

File: backend/main.py β†’ quick_signal()

Calls: tools/tool_signal_fusion.py β†’ fuse_signals(ticker)

Returns:

{
  "ticker": "AAPL",
  "recommendation": "HOLD",
  "confidence": 46,
  "final_score": 0.028,
  "current_price": 260.86,
  "breakdown": { "ml": {...}, "technical": {...}, "fundamental": {...}, "sentiment": {...} },
  "risk_flags": [],
  "price_series": {...},
  "ma20": ..., "ma50": ..., "bb_upper": ..., "bb_lower": ...
}

Cache: 15 minutes in-memory (_QUICK_CACHE). TTL = _QUICK_TTL.

To replace: Swap fuse_signals() in tool_signal_fusion.py. The response shape must keep recommendation, confidence, current_price, breakdown.


GET /stream/{ticker} β€” Deep 6-Agent Analysis (SSE)

File: backend/main.py β†’ stream_analysis()

Calls: orchestrator.py β†’ run_analysis_stream(ticker, user_id)

Returns: Server-Sent Events stream. Each event:

{"agent": "DataAgent", "content": "...", "done": false}

Final event:

{"agent": "__complete__", "content": "<full report>", "recommendation": "BUY", "confidence": 72, "done": true}

Followed by data: [DONE].

Cache: 4 hours in-memory (_STREAM_CACHE). Cached runs replay instantly β€” no LLM called.

Rate limit: 1 real LLM run per IP per 20 min (_IP_RATE_WINDOW). Bypass with ?bypass=<DEV_BYPASS_KEY>.

To replace: Swap run_analysis_stream() in orchestrator.py. Keep the SSE event shape.


GET /portfolio β€” All Holdings Quick Signals

File: backend/main.py β†’ get_portfolio()

Calls: fuse_signals() for each ticker in config/portfolio.py via ThreadPoolExecutor(max_workers=4).

To replace: Change holdings in config/portfolio.py. Signal logic unchanged.


GET /health β€” Dependency Health Check

File: backend/main.py β†’ health_check()

Tests: NewsAPI reachable, ML model file loadable, fuse_signals() end-to-end.

Does NOT test: Groq key validity, LLM reachability. ← Known gap.

Cache: 30 minutes in-memory (_HEALTH_CACHE).


3. Orchestrator β€” 3-Phase Pipeline

File: orchestrator.py

Entry: run_analysis_stream(ticker, user_id) β†’ calls _run_swarm_stream().

Phase 1 ─ DataAgent           _run_data_phase()
Phase 2 ─ Tech + Fund         _run_analysts_parallel()     asyncio.gather()
Phase 3 ─ Report + Risk       _run_report_phase()          sequential

Phase 1 β€” Data Collection

agent = build_data_agent(ctx, memory_context)
agent.run_stream(task=...)

DataAgent calls all 8 tools sequentially (LLM decides order from prompt). Output: data_summary string passed to Phase 2.

Phase 2 β€” Parallel Analysis

tech_resp, fund_resp = await asyncio.gather(
    tech_agent.on_messages(msg, token),
    fund_agent.on_messages(msg, token),
)

Both analysts receive data_summary as input. No tools β€” reasoning only. Output: tech_out, fund_out strings passed to Phase 3.

Phase 3 β€” Report + Risk (sequential, no Swarm)

report_resp = await report_agent.on_messages([...combined...], token)
risk_resp   = await risk_agent.on_messages([...combined + report...], token)

Why no Swarm here: Groq rejects AutoGen's handoff tool JSON schema (required present but properties missing). Running sequentially via on_messages() avoids generating handoff tools entirely.


4. Agents

All agents are AutoGen AssistantAgent. Built by factory functions in agents/.

Agent File Model tier Tools Handoffs Role
DataAgent agents/data_agent.py data (cheap) 8 tools none Fetch all data
TechnicalAnalyst agents/technical_agent.py reasoning none none Technical JSON
FundamentalAnalyst agents/fundamental_agent.py reasoning none none Fundamental JSON
ReportWriter agents/report_agent.py analysis none none Full markdown report
RiskAgent agents/risk_agent.py reasoning none none Risk review
PortfolioAgent agents/portfolio_agent.py reasoning none RiskAgent Optional β€” disabled

Why handoffs are all empty: Groq's API rejects AutoGen's auto-generated handoff tool schemas. All routing is done by the orchestrator directly. Never add handoffs=[...] unless you confirm the LLM provider accepts the schema.

To replace an agent: Edit the build_* factory in its file. Change the system_message for behaviour, model_client for the LLM tier, tools for capabilities. The orchestrator does not need to change.


5. LLM Model Tiers

File: config/settings.py β†’ get_model_client(task)

Task Tier Default model Used by
"data" cheap / fast llama-3.1-8b-instant DataAgent
"reasoning" mid llama-3.3-70b-versatile TechAnalyst, FundAnalyst, RiskAgent
"analysis" best prose llama-3.3-70b-versatile ReportWriter

Priority: Groq (primary) β†’ Gemini (fallback).

Key bug fixed: GROQ_API_KEY must be .strip()-ed β€” HF Spaces adds a trailing \n to secrets, which makes HTTP headers illegal. Already fixed in config/settings.py at all 3 read sites.

To swap provider: Replace _analysis_client(), _reasoning_client(), _cheap_client() in config/settings.py. Keep returning an OpenAIChatCompletionClient-compatible object.

To swap model: Set env vars: ANALYSIS_MODEL, REASONING_MODEL, DATA_MODEL.


6. Data Tools

All tools live in tools/. Called by DataAgent (via LLM tool use) and fuse_signals() (direct Python calls).

Tool File Data source What it returns
get_price_history(ticker) tool_price.py yfinance via tool_finnhub.get_history() Price series, MAs, Bollinger, RSI, MACD
get_financials(ticker) tool_financial.py Finnhub /stock/metric + /profile2 P/E, margins, debt, growth
get_news_sentiment(ticker) tool_news.py Finnhub /company-news + FinBERT Sentiment score, headline count
get_earnings_calendar(ticker) tool_earnings.py yfinance Next earnings date, EPS estimates
compute_signal_ensemble(ticker) tool_signal.py yfinance via tool_finnhub.get_history() 6-indicator vote (RSI, MACD, BB, etc.)
forecast_price(ticker) tool_forecast.py yfinance via tool_finnhub.get_history() Prophet 30-day forecast + confidence interval
predict_signal(ticker) tool_ml_signal.py yfinance via tool_finnhub.get_history() ML BUY/HOLD/SELL + probabilities
fuse_signals(ticker) tool_signal_fusion.py All of the above Weighted fusion score, final recommendation

Data Source Split (important)

Data type Source Why
OHLCV price history yfinance Finnhub free tier returns 403 on /stock/candle
Current price (live) Finnhub /quote Faster, more reliable for single tick
Company profile / sector Finnhub /profile2 Free tier βœ…
News articles Finnhub /company-news Free tier βœ…
Financial metrics Finnhub /stock/metric Free tier βœ…
Sentiment scoring FinBERT (local model) ProsusAI/finbert, loaded once on startup

tool_finnhub.get_history() is a wrapper that calls yfinance internally. All other tools call tool_finnhub.get_history() β€” so replacing yfinance means changing only this one function.


7. ML Pipeline

File Purpose
tools/tool_ml_train.py Training β€” fetches 5y data, builds features, trains GBM+RF+LR stack
tools/tool_ml_features.py Feature engineering (_build_features) and file path constants
tools/tool_ml_signal.py Live prediction β€” loads model, builds features, returns signal

Models

File What
data/signal_model_stock.pkl Stacked classifier (GBM + RF + LR) for individual stocks
data/signal_scaler_stock.pkl StandardScaler fitted on training data
data/signal_selector_stock.pkl Feature selector (drops low-signal features)
data/signal_model_etf.pkl Separate pipeline for ETFs

Key constraint

_build_features() computes dist_52w_high / dist_52w_low which need 252 trading days. Always fetch period="2y" for prediction β€” 13mo is not enough (returns empty features). Training uses 5y so it's fine.

Training trigger

docker-entrypoint.sh trains models at container startup if pkl files are absent. HF free tier has no persistent disk β€” models train on every cold start (~3 min).

To eliminate training delay: Pre-train locally, push pkl files to a HF Dataset, load from there at startup. See "Known Issues" below.


8. Memory

File: memory/memory_manager.py

  • Local: SQLite fallback
  • Production: Supabase (set SUPABASE_URL + SUPABASE_KEY)
  • Stores completed analyses per user, used to build memory_context string injected into agent prompts

9. Environment Variables

Variable Where used Required
GROQ_API_KEY config/settings.py β€” all LLM clients Yes
GOOGLE_API_KEY config/settings.py β€” Gemini fallback No
FINNHUB_API_KEY tools/tool_finnhub.py Yes
NEWS_API_KEY tools/tool_news.py Optional (Finnhub news used if missing)
DEMO_MODE backend/main.py No (default false)
DEV_BYPASS_KEY backend/main.py No (skips IP rate limit if set)
SUPABASE_URL + SUPABASE_KEY memory/ No (SQLite fallback)
NEXT_PUBLIC_BACKEND_URL xenex-ai Next.js frontend Yes (Vercel)
MODAL_BACKEND_URL xenex-ai API routes + admin Yes (Vercel)

HF Spaces secret gotcha: HF appends \n to secrets. All env reads in config/settings.py call .strip() to prevent illegal HTTP headers. Apply .strip() everywhere you read an API key from env.


10. Frontend β†’ Backend Call Map

What Frontend code Next.js layer HF endpoint
Quick signal StockAnalysis.jsx:70 fetch('/quick/AAPL') Rewrite β†’ NEXT_PUBLIC_BACKEND_URL/quick/AAPL /quick/{ticker}
Deep stream AgentStream.jsx:123 new EventSource(BACKEND + '/stream/AAPL') Direct (bypasses Vercel β€” 30s timeout) /stream/{ticker}
Portfolio Admin page Next.js proxy /portfolio
Health Admin page app/api/admin/health/route.ts β†’ MODAL_BACKEND_URL/health /health

Two env var problem: NEXT_PUBLIC_BACKEND_URL (rewrites + AgentStream) and MODAL_BACKEND_URL (API routes + admin) both point to the same HF URL. If one is wrong, different features fail silently.


11. Known Issues & Decisions Log

Issue Root cause Fix applied Date
NameError: run_analysis_stream Import missing in backend/main.py Added import 2026-04-06
Groq Illegal header value \n in HF Spaces secret .strip() on all key reads 2026-04-06
Groq 400 bad handoff schema AutoGen generates required with no properties Removed all handoffs; Phase 3 runs sequentially 2026-04-06
Could not compute features predict_signal used 13mo history β€” not enough for 52w features Changed to 2y 2026-04-06
No data found for ticker tool_finnhub.get_history() called Finnhub /stock/candle β€” 403 on free tier Replaced with yfinance 2026-04-06
HF proxy 404 after restart ML training takes 3 min; HF proxy times out before uvicorn starts Known β€” train models at build time or use HF Dataset for persistence Open
Rate limit blocks testing _IP_LAST_RUN is in-memory, resets on restart Added DEV_BYPASS_KEY query param 2026-04-06

12. How to Test Before Deploying

# Level 1 β€” tool unit tests only (~30s, no server)
./test_local.sh 1

# Level 2 β€” tool tests + all endpoints (~60s)
./test_local.sh 2

# Level 3 β€” full LLM pipeline end-to-end (~2-3 min)
./test_local.sh 3

Requires .env.local with GROQ_API_KEY, FINNHUB_API_KEY, DEV_BYPASS_KEY.

Rule: Always pass Level 2 before pushing to HF.


13. How to Deploy

# 1. Test locally
./test_local.sh 2

# 2. Commit to cost-opt
git add <files> && git commit -m "..."

# 3. Rebuild hf-deploy (orphan branch β€” no binary history)
git checkout hf-deploy
git checkout cost-opt -- .   # or cherry-pick specific commits
git add -A && git commit -m "Deploy: ..."
git push hf hf-deploy:main --force

# 4. Switch back
git checkout cost-opt

Why orphan branch: HF rejects pushes containing binary files (pkl). hf-deploy is a clean branch with no pkl history.


14. Replacing Components in Isolation

Want to replace Change only Contract to preserve
LLM provider (Groq β†’ OpenAI) config/settings.py β€” _*_client() functions Return OpenAIChatCompletionClient-compatible object
LLM model Set ANALYSIS_MODEL / REASONING_MODEL / DATA_MODEL env vars None
Agent behaviour agents/<agent>.py system_message Keep agent name, do not add handoffs
Price history source tools/tool_finnhub.py β†’ get_history() Return pd.DataFrame with Open, High, Low, Close, Volume index DatetimeIndex tz-naive
News source tools/tool_news.py Return {"score": float, "label": str, "articles": [...]}
ML model architecture tools/tool_ml_train.py + tool_ml_features.py Saved as joblib, loaded in tool_ml_signal.py via STOCK_MODEL_PATH
Signal fusion weights tools/tool_signal_fusion.py β†’ BASE_WEIGHTS Must sum to 1.0; keys: ml, technical, fundamental, sentiment
Memory backend memory/backends.py Implement save(), get_history(), get_all_history()
Rate limiting backend/main.py β†’ _IP_LAST_RUN block Return 429 with {"detail": "Rate limit exceeded", "retry_after": N}