Refactored from the original FinBERT + BART notebook into a small Python package that runs Gemma 4 through either HuggingFace Transformers on a single Google Colab GPU or a local Ollama server, with multi-agent Bull-vs-Bear reasoning and a backtest that uses the same deterministic signal as the live pipeline.
Status: Phase 1 complete ยท 84 tests passing ยท all Critical and Major review findings resolved (see CODE_REVIEW.md).
| Feature | Module | Why it matters |
|---|---|---|
| ๐ฃ๏ธ Multi-agent Bull vs Bear debate | src/agents.py |
Two Gemma calls argue opposite sides; a third judges. Surfaces both upside and downside, reduces overconfidence. |
| โฎ๏ธ Backtesting framework | src/backtest.py |
Replays the pipeline over the past ~21 trading days and reports hit rate / Sharpe / max drawdown โ you can finally measure the system. |
| ๐ Gradio web UI | webapp.py |
One command (python webapp.py) โ public *.gradio.live link valid 72 h. Tabs for live analysis + backtesting. |
| โ๏ธ Deterministic aggregation | src/preprocessing.py |
A single aggregate_polarity() function is used by both live and backtest paths so the two are numerically consistent. |
| ๐งช Mock-based integration tests | tests/ |
84 tests, no GPU / Ollama server / API keys required โ runs offline. |
INPUT (symbol, horizon)
โ
โโโบ Stage 1 Data collection (yfinance daily + intraday + News API)
โโโบ Stage 2 Pre-processing (clean โข TF-IDF dedup โข time-decay
โ โข price features on DAILY series:
โ SMA/EMA/RSI/ATR/pct_1d/pct_5d/pct_30d)
โโโบ Stage 3 Per-news scoring (Gemma 4 โ "scores" array, index-aligned)
โโโบ Aggregation (preprocessing.aggregate_polarity
โ โ single source of truth)
โโโบ Stage 4 Synthesis & advice
โ โ
โ โโโบ (default) src/agents.py Bull โ Bear โ Judge debate
โ โโโบ (--no-debate) src/stages.py single-call synthesize
โ
โโโบ Stage 5 Presentation (stdout โข Plotly โข Gradio)
Every stage talks to the LLM through one small abstraction layer
(src/llm/), so the engine is a config switch โ no pipeline or UI code
changes:
LLM_BACKEND=hfโ a local open-weight model (Gemma 4 / Phi-4 / Qwen / โฆ) via HuggingFace transformers, 4-bit, single GPU. Loaded once per session viafunctools.lru_cache.LLM_BACKEND=ollamaโ a local model served by Ollama and called throughhttp://localhost:11434/api/chat. The Ollama daemon owns model loading and quantization, so the Python process does not import PyTorch.
See LLM backends below.
.
โโโ main.py โ CLI: `analyze` and `backtest` subcommands
โโโ webapp.py โ Gradio app (analyze + backtest tabs)
โโโ demo_ui.py โ Mock-data Plotly preview (no GPU / no keys needed)
โโโ config.py โ env-driven settings
โโโ pytest.ini
โโโ requirements.txt
โโโ requirements-dev.txt
โโโ .env.example
โโโ .gitignore
โโโ README.md โ this file
โโโ CODE_REVIEW.md โ Phase 1 review (all Critical/Major resolved)
โโโ src/
โ โโโ apis.py โ Stage 1: yfinance (daily + intraday) + News API
โ โโโ preprocessing.py โ Stage 2: cleaning, dedup, time-decay, features,
โ โ aggregate_polarity, stance_from_polarity
โ โโโ llm/ โ LLM abstraction (pluggable backends)
โ โ โโโ __init__.py โ generate / generate_json dispatcher + JSON repair
โ โ โโโ hf_local.py โ local model (Gemma/Phi/Qwen, 4-bit) loader
โ โ โโโ ollama_local.py โ local Ollama REST API adapter
โ โโโ stages.py โ Stage 3 + (alt) single-call Stage 4
โ โโโ agents.py โ (default) Bull / Bear / Judge debate
โ โโโ backtest.py โ Replays the pipeline on past data
โ โโโ pipeline.py โ Orchestrator โ AnalysisResult
โ โโโ display.py โ Text / Plotly / ipywidgets renderers
โโโ tests/
โโโ conftest.py โ Mocks for yfinance + NewsAPI + LLM
โโโ test_apis.py โ yfinance schema normalisation + wrapper contracts
โโโ test_preprocessing.py
โโโ test_llm.py โ JSON extractor + generate_json retry
โโโ test_ollama_backend.py โ Ollama HTTP adapter + dispatcher
โโโ test_stages.py โ structured score contract + title restoration
โโโ test_pipeline.py
โโโ test_backtest.py
โโโ test_webapp.py
- Open the project in VSCode and connect to a Colab runtime. Pick Runtime โ Change runtime type โ GPU (T4 / L4 / A100).
- Install requirements on the Colab runtime:
pip install -q -r requirements.txt
- Create
.envnext tomain.py(copy from.env.example):Fill in:cp .env.example .env
NEWS_API_KEYLLM_BACKEND(hffor Transformers on a CUDA GPU,ollamafor a local Ollama server โ see LLM backends)HF_TOKEN(whenLLM_BACKEND=hf; also accept the model licence on Hugging Face for gated models like Gemma)MODEL_ID(optional override, e.g.google/gemma-4-4b-it,microsoft/phi-4)GEMMA_ATTN_IMPL(optional, defaulteager)
# Live analysis with Bull vs Bear debate (default)
python main.py analyze AAPL
# Same but force single-call synthesis (faster, less depth)
python main.py analyze AAPL --no-debate
# Open the Plotly candlestick after analysis
python main.py analyze TSLA --show-chart
# Machine-readable output
python main.py analyze NVDA --json
# Backtest the last 21 trading days (capped at 22 by the News API free tier)
python main.py backtest AAPL --days 21 --show-chart
# Backtest with JSON output (for piping into another script)
python main.py backtest NVDA --json > nvda_backtest.jsonpython webapp.pyThis prints a *.gradio.live link (valid 72 h) you can open on any device.
The UI has two tabs:
- Analyze โ symbol, horizon, debate on/off โ stance + Bull case + Bear case + per-news table + Plotly candlestick.
- Backtest โ symbol + days slider (7โ22) โ metrics table + equity curve
- per-day decision log.
A concurrency queue (max_size=8) is enabled so a second user clicking
"Analyze" while Gemma is busy gets queued instead of timing out.
from src.display import launch_widget_ui
launch_widget_ui() # ipywidgets form: symbol + horizon + debate toggleโฆor programmatically:
from src.pipeline import analyze
from src.backtest import backtest
from src.display import render_advice_text, render_backtest_text
result = analyze("AAPL", horizon="1w")
print(render_advice_text(result))
bt = backtest("AAPL", backtest_days=21)
print(render_backtest_text(bt))demo_ui.py ships a self-contained Plotly preview with mock data so you can
inspect the look-and-feel of the dashboard without a GPU or API keys:
pip install plotly pandas numpy
python demo_ui.py AAPL # opens demo_preview.html in your browserThe pipeline is engine-agnostic. Pick the backend with LLM_BACKEND; the CLI,
Gradio UI, Plotly charts, and backtest all behave identically either way โ
only the reasoning quality and where it runs change.
Runs any instruction-tuned causal LM with a chat template on a single GPU
(4-bit). Set the repo with MODEL_ID:
LLM_BACKEND=hf MODEL_ID=google/gemma-4-12b-it python main.py analyze AAPL
LLM_BACKEND=hf MODEL_ID=microsoft/phi-4 python main.py analyze AAPLNeeds HF_TOKEN for gated models (e.g. Gemma) and a CUDA GPU (Colab T4/L4/A100).
GEMMA_MODEL_ID is still honoured as a fallback for MODEL_ID.
Calls Ollama's non-streaming /api/chat endpoint. Ollama owns model loading,
quantization, and device placement; the Python application only sends prompts
over HTTP. requests is already included in requirements.txt, so no extra
Python SDK is needed.
# Recommended local setup (Python 3.10+):
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-ollama.txt
# Start the Ollama app or daemon, then download the model once.
ollama serve
ollama pull gemma4:12b
LLM_BACKEND=ollama OLLAMA_MODEL=gemma4:12b \
python main.py analyze AAPLAvailable settings:
OLLAMA_BASE_URLโ defaulthttp://localhost:11434OLLAMA_MODELโ defaultgemma4:12bOLLAMA_TIMEOUTโ default600secondsOLLAMA_NUM_CTXโ default8192; larger values consume more memoryOLLAMA_KEEP_ALIVEโ default10mOLLAMA_THINKโ defaultfalse; enable only after measuring quality/latency
Caveats
- Keep Ollama bound to localhost unless the remote endpoint is protected by appropriate network controls and authentication.
- Stop a resident Ollama model before loading a large HuggingFace model on the
same machine if RAM/VRAM is constrained:
ollama stop gemma4:12b. - Keep a backtest run on one backend so its metrics stay comparable.
# Install dev dependencies (pytest + everything in requirements.txt)
pip install -r requirements-dev.txt
# Run the whole suite
pytest
# Run a single file or filter by name
pytest tests/test_preprocessing.py -v
pytest -k "aggregate_polarity"The suite uses monkeypatch fixtures (autouse=True in
tests/conftest.py) to stub yfinance, News API, and both
LLM backends โ every test runs offline with no GPU, Ollama server, or API
keys.
| Metric | Meaning |
|---|---|
hit_rate |
Of days where the pipeline took a position (BUY or SELL), what fraction matched the next-day direction. |
cumulative_return |
What 1 unit of capital would have grown to over the period if you followed every signal (long on BUY, short on SELL, flat on HOLD). |
annualised_sharpe |
Daily mean / daily std ร โ252. >1 is decent, >2 is rare. |
max_drawdown |
Worst peak-to-trough loss along the equity curve. |
Caveats:
- The free News API tier only exposes ~30 days of history.
--daysis hard-capped at 22 trading days (= 30 โ 7 lookback โ 1 next-day). Passing a larger value prints a warning and silently caps. - No transaction costs are modelled. Real trading would subtract spread + fees.
- Sample size is small โ treat as a sanity check, not statistical proof.
- Same aggregation as live.
backtestandanalyzeboth callpreprocessing.aggregate_polarity(), so a number you see in the backtest is exactly what the live system would compute on the same news.
All thresholds, weights, and window sizes are exposed in
config.py under PipelineConfig:
| Knob | Default | Purpose |
|---|---|---|
stance_threshold |
0.15 | min ` |
impact_weights |
{low:0.5, medium:1.0, high:1.5} |
per-news weighting |
time_decay_half_life_days |
3.5 | sentiment half-life |
news_top_k |
10 | max news fed to Gemma |
news_dedup_threshold |
0.85 | TF-IDF cosine for dedup |
yfinance_daily_period |
"6mo" |
daily OHLCV window for features/backtest |
yfinance_intraday_period |
"5d" |
recent intraday history window |
yfinance_intraday_interval |
"5m" |
intraday candle interval |
min_rsi_samples |
15 | strict RSI window |
max_backtest_days |
22 | News API free-tier safe cap |
| Model | โ VRAM | Fits on Colab |
|---|---|---|
| Gemma 4 ยท 4B | 2.5 GB | T4 โ |
| Gemma 4 ยท 12B (default) | 7 GB | T4 โ |
| Gemma 4 ยท 27B | 15 GB | A100 โ (L4 marginal) |
| Phi-4 ยท 14B | 9 GB | T4 โ |
Override the default via env var:
export MODEL_ID=google/gemma-4-4b-it # or microsoft/phi-4, Qwen/Qwen2.5-14B-Instruct, ...
โ ๏ธ Verify the exact Hugging Face id on the model card and updateMODEL_ID(orconfig.py) accordingly. For Ollama, use its model tag instead, for exampleOLLAMA_MODEL=gemma4:12b.
| Removed | Why |
|---|---|
FinBERT (yiyanghkust/finbert-tone) |
Gemma 4 covers per-news polarity + nuance |
BART (facebook/bart-large-cnn) |
Gemma 4 handles summary + advice |
Sentence-BERT embeddings (get_embeddings) |
Was dead code; dedup now via TF-IDF |
| Fine-tuning on IMDB | Domain mismatch + Colab Pro not suited |
| Two parallel UI handlers (before/after) | Collapsed into one |
Model explanation/ (28 .md) |
Coverage already lives in module docstrings |
| Tutorial PNG screenshots | Out-of-date with the new UI |
Phase 1 โ "Make it credible" โ complete
- Multi-agent Bull vs Bear debate
- Backtesting framework
- Gradio web UI
- Unified live/backtest aggregation
- Mock-based integration tests
- All Critical + Major review findings resolved
Phase 2 โ "Make it smarter" โ next
- RAG over historical news + past predictions (BGE-M3 + FAISS)
- Time-series forecast (Chronos / TimesFM)
- Reddit + WSB sentiment (PRAW)
- SEC filings ingest (EDGAR)
Phase 3 โ "Make it production"
- Watchlist + scheduled Telegram alerts
- PDF report export
- Earnings call audio (Whisper)
- Portfolio optimiser (PyPortfolioOpt)