Alpha Trace
Full-stack data mining research platform analyzing how geopolitical events and community sentiment causally influence stock market behavior across 5 global exchanges (2.2M+ hourly records) using Granger causality, XGBoost, Neo4j graph analysis, and a GraphQL dashboard.
I owned the system architecture, the ML pipeline, and the full-stack dashboard: registry-driven onboarding (markets.json + onboard_market.py), the walk-forward validation protocol and per-market gating in xgboost_model.py/company_model.py, the live-signal ledger and weekly regression-guarded retrain (predict_live.py, refresh_market.py), the GraphQL API (40 resolvers, 52 types in dashboard/api/), and all 19 screens of the Next.js terminal including the canvas-based orthographic globe. A second team member (backend/data engineering) contributed to the data-ingestion layer. Every architectural decision, the validation discipline, and the eventual removal of two markets after they failed on evidence were mine to make and defend.
// the problem
What was broken
Retail and institutional "AI predicts the market" products backtest well and then fail live, and nobody ever discloses when the underlying signal stops working — so risk and compliance teams can't adopt them. Separately, there is no accessible tool that connects a specific news event or public figure's statement to a measurable, evidenced market move; that connection lives in analysts' heads as folklore, redone by hand in spreadsheets every time it's needed.
// constraints
What made it hard
Free-tier-only data: GDELT and Yahoo Finance rate-limit aggressively (GDELT returns HTTP 429 within minutes of sustained use), so every scraper had to be resume-safe and tolerate multi-day partial completion. The project ran as a solo-architected build against a hard hackathon/demo deadline, on a single Windows development machine, with no dedicated infra budget — which ruled out a database server and forced a file-based (CSV/parquet) architecture that still had to serve a live dashboard without restarts.
// architecture
How it fits together
The system is split into three layers that don't share process boundaries on purpose: a pipeline layer that writes plain files (CSV/parquet/JSON), a GraphQL layer that reads those files fresh on every request, and a Next.js terminal that queries GraphQL. The reason for the file-based store instead of a database is explicit in the code: at this scale (~1.65M rows) a database server is operational overhead with no query the files can't already answer in milliseconds, and file mtimes give "the API always serves what's on disk" for free — no cache-invalidation logic, no restart-on-deploy step. The tradeoff is real: there's no concurrent-write safety, which is why the live ledger (live_signals.parquet) is strictly append-only and every write path (predict_live.py, refresh_market.py) treats existing rows as immutable once an outcome resolves.
The second architectural decision that shapes everything downstream is the market registry (markets.json): every pipeline script, the API, and every dashboard dropdown reads which markets exist from that one file instead of hardcoding a list. That decision was made after discovering — repeatedly, across several commits — that a hardcoded market list in one file and not another caused dashboards to silently disagree about how many markets existed. onboard_market.py turns adding a market into one CLI command that runs the whole chain (scrape → features → train → validate → verdict) against that registry.
Scheduler
Windows Task Scheduler fires update_data.py nightly (03:45) and refresh_market.py weekly
update_data.py
Appends new price/FX/VIX rows and re-fetches the current GDELT month; resume-safe, tolerates partial 429 failures
feature_engineering.py
Rebuilds tone/return/volatility features per market from markets.json's registry, no hardcoded market list
predict_live.py
Trains LR+XGBoost on all history with the SAME validated per-market gates as the backtest engine, emits one forward signal per market
live_signals.parquet
Append-only ledger — signal_ts is written BEFORE the outcome is knowable; rows are never edited, only outcome-backfilled later
GraphQL API (Strawberry)
loaders.py reads the parquet fresh (mtime-checked), computes accuracy through one canonical stats function shared by every screen
Next.js dashboard
/signals and /performance query the ledger; a signal below its market's validated confidence gate is shown as an abstention, not a guess
// challenges
Where I got stuck
Pages disagreeing about their own numbers
Different screens computed "high-confidence accuracy" with slightly different windowing logic, so the same market showed two different accuracy figures depending on which page you were on. I traced it to duplicated statistics code and replaced every accuracy computation across the API with one function (_hc_stats/_val_slice in loaders.py) so no two screens can disagree by construction.
A retrain that quietly got worse
The first live run of the weekly retrain script dropped one market's validated accuracy by more than 5 percentage points against the recorded baseline. Rather than trusting the new numbers, refresh_market.py snapshots the model outputs before retraining and calls verify_model.py as a hard gate — if any market regresses past a threshold, it restores the snapshot automatically and the bad model never reaches the dashboard. This fired for real on the first run, not in a test.
A UK signal that looked promising eight times in a row
Feature engineering, gate tuning, calibration, sector sub-models, an alternate horizon, mid-cap tickers, and hourly intraday bars were each tried against UK data and each looked like an improvement on the training folds — and each one collapsed when checked against the frozen validation folds that were never touched during tuning. The discipline that mattered was refusing to let a promising dev-fold number override a bad validation-fold number, even on the eighth attempt. The market was eventually dropped rather than shipped with an unsupported claim.
Free-tier rate limiting breaking multi-day scrapes
GDELT throttles per IP within minutes of sustained requests, which made a naive scraper unusable for pulling years of history across five countries. Scrapers were rewritten to skip already-fetched months and resume cleanly after a 429, so a scrape could be stopped and restarted (including across IP changes) without re-downloading or duplicating data.
A shell-escaping bug that silently zeroed out a whole feature
A regex word-boundary pattern (\b) built through a heredoc got corrupted into a literal backspace character by the shell, which made a headline-matching filter match nothing — the globe's news-evidence panel returned zero headlines for every market with no error thrown anywhere. It only surfaced because the empty result looked suspicious enough to check by hand; there was no test that would have caught it.
// new ground
What I hadn't used before
figure_impact.py was the first time I'd implemented an event-study methodology (mean-adjusted abnormal returns against an estimation window, per-event t-statistics, a confounding-event flag) rather than just correlating a feature against a return series.// takeaways
What I took away
- An accuracy number without its sample size and confidence interval is not a claim, it's a guess wearing a decimal point.
- A model that abstains honestly is more valuable to a real user than one that never admits uncertainty — the UK market's rejection became the strongest credibility story in the whole project, not a weakness to hide.
- Free public data sources (GDELT, Yahoo, Guardian) can replace a paid data budget entirely, but only if every scraper is written to survive rate-limiting from the start, not patched in afterward.
- A regression guard that can auto-rollback a bad model is worth building before you need it — the first time it fired was in production, not in a drill.
- Duplicating a small calculation (like an accuracy formula) into more than one file is how a dashboard ends up silently lying to itself; one canonical function per computed number is worth the refactor.
// the journey
How it actually went
This started as a five-market academic sentiment-mining exercise and turned into something closer to a real trading terminal partway through, once it became clear that "does news sentiment correlate with returns" wasn't an interesting enough claim on its own — the interesting question was whether the model could be trusted to know when it didn't know. That reframing is what pulled in the validation protocol, the confidence intervals, and eventually the honest abstention framework that ended up being the actual product.
The UK market cost the most time and taught the most. Every single lever I could find — new features, recalibrated gates, sector-level sub-models, a longer prediction horizon, mid-cap tickers instead of large-cap, hourly bars instead of daily — looked like a fix on the training folds and then fell apart on the validation folds, one after another, across eight separate attempts. It would have been easy to ship the sixth or seventh attempt's dev-fold number and call it done; the fact that none of them held up on data the model hadn't seen is the reason I trust the markets that did pass.
The moment it clicked was building the weekly regression guard and watching it actually fire on its first real run — a retrain came back objectively worse on three markets, and the system caught it and rolled itself back before anything bad reached the dashboard, with no one watching. That's when the project stopped feeling like a model and started feeling like a system with a safety net.
If I did it again, I'd write the canonical-stats module and the shared market registry on day one instead of after discovering the disagreement bugs they were built to fix — both were retrofits onto code that had already grown five or six independent copies of logic that should have been one function from the start. I'd also add automated tests around the statistics functions and the ledger's append-only guarantee; right now that correctness is enforced by convention and by me checking the numbers by hand, which doesn't scale and didn't catch the shell-escaping bug until it was visible in the UI.
// outcome
What it changed
A working system covering 5 markets (NYSE, TSX, TSE, ASX, KOSPI) after two (UK, Hong Kong) were deliberately removed following documented, repeated validation failures. The production model store holds roughly 1.65M walk-forward predictions across 468 companies; the market-level engine's frozen out-of-sample validation reaches 79.7% accuracy on its strongest market (n=59) and a pooled 62.3% across all five markets (n=738) against a 50% baseline. The experiment log (Community/Output/Model/experiments.md) records 23 dated attempts, the majority of them rejections. The live forward-signal ledger and the weekly guarded retrain were both running unattended on a schedule as of this writing, with the regression guard having already triggered one real rollback.
// overview
In more detail
AlphaTrace investigates whether community-level signals — GDELT news sentiment, World Bank macroeconomic indicators, OECD data, Reddit discourse — causally influence stock returns and volatility across NYSE, LSE, TSE, TSX, and HKEX over 5 years (2020–2025).
The pipeline: async multi-source scrapers with rate-limiting and checkpoint-resume → 7-module Neo4j ETL (2,500+ nodes: Company, Country, Event, CommunityIndicator) → 80+ engineered features per country → 8 data mining algorithms → FastAPI + Strawberry GraphQL API (20+ query types) → Next.js 14 dashboard (9 pages, Nivo / ECharts / Recharts / Tableau).
Algorithms: Granger Causality, VAR Impulse Response, Event Study (CAR), K-Means (stocks + market regimes), XGBoost (binary next-day direction, TimeSeriesSplit validation), SHAP explainability, PCA (52 macro indicators → 5 components).
// features
What it does
- Multi-source async scrapers — GDELT DOC 2.0, World Bank API, OECD SDMX, Reddit PRAW, Guardian/NYT/GNews with checkpoint-resume
- 7-module Neo4j ETL — 2,500+ nodes with INFLUENCED_BY / CORRELATED_WITH edges
- 80+ engineered features per country: daily returns, volatility regimes, GDELT tone lags, macro PCA components
- Granger Causality + VAR Impulse Response — does community data cause stock returns?
- Event Study (CAR) — cumulative abnormal returns in [-5, +10] windows around GDELT tone spikes
- K-Means clustering (k=4 stocks, k=3 market regimes: Growth / Stagnation / Crisis)
- XGBoost binary classifier with TimeSeriesSplit cross-validation + SHAP explainability
- FastAPI + Strawberry GraphQL API — 20+ typed query endpoints exposing all algorithm outputs
- 9-page Next.js 14 dashboard — Granger heatmaps, VAR impulse graphs, regime timelines, SHAP importance
// stack