Stock Data Platform
Unified market data warehouse: 10 tickers, full price history, 4 data sources. Kafka streaming, Airflow orchestration, TimescaleDB star schema, Dockerized.
OVERVIEW
Unified batch + streaming market data warehouse tracking the full price history of 10 major US equities. Combines 4 data sources (yfinance prices, SEC EDGAR filings, FRED macro indicators, and Alpaca hourly bars) through a live Kafka stream and 23 Airflow DAGs into a 10-table star schema on TimescaleDB, shown on a Chart.js dashboard. Fully containerized with 7 Docker services.
ARRIVED AS
“Build a unified market data warehouse combining real-time streaming with batch historical data from multiple sources.”
WHAT I BUILT
- 014 data sources (yfinance, SEC EDGAR, FRED, Alpaca): a Kafka producer polls yfinance for live prices every 15 seconds in market hours, and Airflow loads the rest on a schedule.
- 0223 Airflow DAGs (one ETL DAG per ticker plus 13 more) orchestrate batch ETL, backfills, and aggregations across 10 major tickers.
- 0310-table star schema in TimescaleDB (3 dimension + 7 fact tables) with dimensional modeling for prices, fundamentals, earnings, SEC filings, and macro data.
WHAT CHANGED
- Full price history, back to each ticker's first traded day, for 10 major US equities (AAPL, NVDA, MSFT, GOOG, AMZN, META, TSLA, JPM, NFLX, DIS).
- Fully containerized with 7 Docker services, single command deployment.
- A Bloomberg-style Chart.js dashboard for price trends, fundamentals, earnings surprises, and macro indicators.
ARCHITECTURE
Four upstream sources feed the warehouse on two paths. Live prices stream through a Kafka producer into a broker, where a consumer batches and upserts them into TimescaleDB. Batch data (daily and hourly prices, fundamentals, earnings, SEC filings, macro indicators) moves through Airflow DAGs that extract, normalize, and load on a schedule. Everything lands in a 10-table star schema and surfaces through a Chart.js dashboard.
Data flow
click a stage
A Kafka producer polls yfinance and publishes live prices to the broker every 15s during market hours; SEC EDGAR, FRED, and Alpaca come in through Airflow.
COMPONENT
kafka-producer / kafka-consumerCustom containers that ingest market data and sink it into TimescaleDB in batches.
ALSO IN THE BUILD
kafka + zookeeperMessage broker and coordination for the live price stream.
Decisions, with the cost of each.
A decision without its trade-off is marketing. Each row says what was chosen, why, and what it gave up.
TimescaleDB over plain Postgres
Decades of daily prices across 10 tickers plus hourly bars and macro series is time-series-heavy; TimescaleDB hypertables keep range queries fast while staying SQL-native.
AT THE COST OF
One more extension to operate versus reaching for a dedicated TSDB like InfluxDB and losing relational joins to the dimension tables.
Batch-and-flush Kafka consumer, not per-message inserts
Upserting in batches of 50 (or every 10s, whichever comes first) cuts write amplification and keeps the warehouse current without hammering it.
AT THE COST OF
A small bounded staleness window in exchange for far fewer transactions.
Incremental DAGs keyed on last-loaded date
Each ETL run reads the latest date already in the warehouse and re-reads only a 10-day overlap from there, so reruns are idempotent and late provider corrections are picked up; the first run loads the full history.
AT THE COST OF
More bookkeeping per DAG than a naive full reload.
The part that mattered.
The facts behind the work, and the code that produced them.
- price history per ticker
- Full
- major US equities
- 10
- Airflow DAGs
- 23
- Docker services
- 7
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE TABLE IF NOT EXISTS dim_company (
company_key SERIAL PRIMARY KEY,
ticker TEXT NOT NULL UNIQUE,
company_name TEXT NOT NULL,
sector TEXT,
industry TEXT,
exchange TEXT,
is_current BOOLEAN NOT NULL DEFAULT TRUE,
effective_date DATE NOT NULL DEFAULT CURRENT_DATE
);
CREATE TABLE IF NOT EXISTS fact_stock_price_daily (
date DATE NOT NULL,
company_key INT NOT NULL REFERENCES dim_company(company_key),
open DOUBLE PRECISION NOT NULL,
high DOUBLE PRECISION NOT NULL,
low DOUBLE PRECISION NOT NULL,
close DOUBLE PRECISION NOT NULL,
volume BIGINT NOT NULL,
PRIMARY KEY (date, company_key)
);
Surrogate-keyed company dimension joined to a daily price fact, keyed on (date, company_key) so it becomes a clean TimescaleDB hypertable.
def _connect_kafka():
for attempt in range(1, MAX_RETRIES + 1):
try:
consumer = KafkaConsumer(
KAFKA_TOPIC,
bootstrap_servers=[os.environ.get("KAFKA_BOOTSTRAP", "...:9092")],
auto_offset_reset="earliest",
group_id="stock-data-consumer",
value_deserializer=lambda m: json.loads(m.decode("utf-8")),
)
return consumer
except Exception as e:
delay = min(BACKOFF_BASE * (2 ** (attempt - 1)), BACKOFF_CAP)
time.sleep(delay)
raise ConnectionError(f"Failed to connect after {MAX_RETRIES} attempts")
# batch by size OR time, whichever trips first
if batch and (len(batch) >= BATCH_SIZE or now - last_flush >= FLUSH_INTERVAL):
conn = _flush_batch(conn, batch)
batch, last_flush = [], now
Exponential backoff on connect, then a size-or-time batch flush that reconnects to the DB and retries the batch on failure rather than dropping data.
def _normalize_columns(df, ticker):
"""Map yfinance columns (with or without ticker suffix) to open/high/low/close/volume."""
if isinstance(df.columns, pd.MultiIndex):
df.columns = ["_".join(col) for col in df.columns]
col_map = {}
for std in ["Open", "High", "Low", "Close", "Volume"]:
suffixed = f"{std}_{ticker}"
if suffixed in df.columns:
col_map[suffixed] = std.lower()
elif std in df.columns:
col_map[std] = std.lower()
return df.rename(columns=col_map)
# re-read a short overlap from the last loaded date -> reruns are idempotent;
# the first run has no last date and takes the full history
last_date = _get_last_loaded_date(ticker)
if last_date:
start_date = last_date - timedelta(days=OVERLAP_DAYS)
df = _normalize_columns(yf.download(ticker, start=start_date, end=end_date, progress=False), ticker)
else:
df = _normalize_columns(yf.download(ticker, period="max", progress=False), ticker)
Column normalization handles yfinance's shifting multi-index, and the last-loaded-date lookup keeps each DAG run incremental and safe to rerun.
LEARNED
Time-or-size batching beats both extremes
Per-message inserts melt the warehouse; pure time batching lags. Flushing on whichever of size/time trips first kept writes cheap and data fresh.
Idempotency is a schema property, not a script
Keying facts on (date, company_key) and reading the last-loaded date made reruns and backfills boring, which is exactly what you want from a pipeline.
NOT DONE YET
- Add great-expectations data-quality gates between extract and load.
- Promote the dashboard to a hosted service with auth instead of a static snapshot.