Spaces:
Sleeping
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_contextstring 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} |