Projects

Korean Price Monitor

A data engineering pipeline that asks whether Korea's official CPI captures what people actually pay.

TimelineFeb 2026 – Apr 2026
RoleSole engineer
SourcesKOSTAT (XML, weekly) · ECOS (JSON, monthly)
StackPython · PostgreSQL · Docker · Streamlit

Overview

Two government APIs publish Korean consumer price data in different formats at different frequencies. This pipeline joins them, validates the data quality of every run, and answers a single question: when official CPI says inflation is X%, is that what consumers are actually paying?

I noticed grocery bills weren't tracking with the official inflation numbers and wanted to know whether my impression held at scale. Statistics Korea (KOSTAT) publishes product-level e-commerce prices weekly in XML — ~600K product listings per collection across 124 item categories. The Bank of Korea (ECOS) publishes monthly CPI indices in JSON, with 1,743 hierarchical items keyed to a base year of 2020. They share classification codes, which means the comparison is possible — but neither source produces a joined view, neither is robust against the other's schema drift, and neither validates its own freshness or completeness. The pipeline handles all of that: defensive API integration, raw-data preservation, six quality checks with thresholds calibrated against real distributions, and a 4-page Streamlit dashboard that produces the "Price vs. CPI" view that started the question.

Stack

language
Python 3.10+
data engineering
PostgreSQL 16 (Docker) raw → mart architecture monthly-partitioned tables UPSERT idempotent ingestion XML & JSON parsers
quality & monitoring
6-check validation suite schema drift detection Slack webhook alerting
orchestration
cron structured logging chosen over Airflow (ADR-004)
front-end
Streamlit (4 pages)
testing & ops
pytest (23 tests) Docker Compose env-keyed config
data sources
KOSTAT API ECOS API

The Problem

The official inflation index and the prices in front of consumers come from the same government but don't talk to each other.

Korean consumer price data is published by two agencies in incompatible formats. KOSTAT publishes the granular reality — actual e-commerce prices, product by product, ~600K listings per collection cycle — in XML, weekly, with a ~2-week lag. ECOS publishes the official inflation summary — CPI indices, 1,743 categories, base year 2020 — in JSON, monthly. The two sources share classification codes, so the comparison is technically possible: for any given category, you could check whether product prices are tracking the official CPI or drifting from it. But nobody had built that joined view, and crucially, nobody was validating that either source had returned good data on any given run. KOSTAT in particular has undocumented API behaviors — successful responses omit the result code that the documentation says is always present, item codes are 6 characters where docs say 7 — which means a naive pipeline silently produces garbage on a bad day.

💡
How might we
build a pipeline that not only joins the two sources but is honest with itself about when either source has returned data it shouldn't trust?

Architecture

Eight Architecture Decision Records — every major choice has its rationale in the repo. The discipline of writing them down on a solo project was overkill; doing it anyway kept the pipeline coherent.

ADR Decision Rationale
ADR-001KOSTAT + ECOS over KAMISKAMIS required company registration. KOSTAT turned out to have richer data — individual product listings vs. aggregated.
ADR-002PostgreSQL via Docker over DuckDB/SQLiteNeed partitioned tables, concurrent writes during collection, and durable raw storage. Docker keeps the dev environment portable.
ADR-003Store raw, aggregate laterRe-aggregation should be possible without re-fetching. Raw stays untouched; SQL transforms produce the mart.
ADR-004Cron + Python over AirflowTwo sources on weekly/monthly cycles. Airflow's orchestration complexity doesn't justify the maintenance overhead at this scale.
ADR-005Pre-build API verification stepThe first thing the pipeline should do on a new endpoint is print what it returns. Costs nothing; prevents a class of silent failures.
ADR-006Defensive KOSTAT parsingAPI docs lie. Successful responses omit the result code; item codes are 6 chars where docs say 7. Parser written against actual behavior, not docs.
ADR-007Quality thresholds calibrated against real distributionsInitial CPI range [80, 130] (from "CPI ≈ 100") produced 143 false positives. Real sub-indices range 47–211. Widened to [30, 250] after looking at the data.
ADR-008Streamlit over GrafanaThe dashboard's job is analytical insight (Price vs. CPI overlays), not infra monitoring. Streamlit is the right tool for that.

Pipeline

Two collectors hit the source APIs on independent schedules. Schema drift is checked first, raw data lands unaggregated in Postgres, quality is validated, and a separate SQL stage produces the analytical marts.

# data flow KOSTAT API (product prices) ECOS API (CPI indices) │ │ ▼ ▼ ┌──────────────────────────────────────────┐ │ Schema Drift Detection │ ← baseline comparison per run └──────────────────────────────────────────┘ │ │ ▼ ▼ collect_kostat.py collect_ecos.py (XML, adaptive date probe) (JSON, pagination) │ │ ▼ ▼ ┌──────────────────────────────────────────┐ │ PostgreSQL (Docker) │ │ raw.kostat_products │ raw.ecos_indices │ │ (partitioned monthly)│ (dedup index) │ └──────────────────────────────────────────┘ │ │ ▼ ▼ ┌──────────────────────────────────────────┐ │ Quality Validation │ ← 6 checks · all thresholds data-calibrated │ freshness · completeness · null ratio │ │ anomaly detection (IQR) · CPI range │ └──────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────┐ │ Aggregation (SQL) │ │ mart.daily_price_summary (median/IQR) │ │ mart.monthly_cpi_index (latest-wins) │ │ mart.price_vs_cpi (joined view) │ ← the answer page └──────────────────────────────────────────┘ │ ▼ Alerts (optional Slack webhook)

Quality checks (every threshold justified)

Check Threshold Rationale
KOSTAT freshness> 21 days → WARNWeekly updates + 2-week lag; 21 days = 1 missed cycle.
ECOS freshness> 45 days → WARNMonthly updates; 45 days = 1 missed cycle.
Item completeness< 90% → WARNSome missing items per date is normal; < 90% is systematic.
Null sale_price> 10% → WARNProducts should always have prices; high nulls = parsing issue.
ECOS CPI rangeoutside [30, 250] → WARNSub-indices legitimately range widely. Calibrated after [80,130] produced 143 false positives.
Price anomalieschange > 2× IQR → WARNIQR-based; robust to non-normal distributions.

Repo structure

price-monitor-pipeline/ ├── src/ │ ├── main.py # CLI entrypoint │ ├── dashboard.py # Streamlit dashboard (4 pages) │ └── pipeline/ │ ├── config.py # API keys, thresholds │ ├── db.py # Connection + CollectionLog │ ├── collect_kostat.py # XML, adaptive probing │ ├── collect_ecos.py # JSON, pagination │ ├── aggregate.py # raw → mart (SQL) │ ├── quality.py # 6 quality checks │ ├── schema_check.py # drift detection │ └── alerts.py # Slack webhook ├── db/ # DDL + migrations ├── tests/ # 23 unit + integration tests ├── scripts/cron_collect.sh # cron wrapper ├── docker-compose.yml ├── 01-CONTEXT.md / ROADMAP / DECISIONS / JOURNAL / SCHEMA-DESIGN # 8 ADRs in DECISIONS

Dashboard

A 4-page Streamlit dashboard: Price Trends, Price vs. CPI (the original question), Data Quality Health, and Pipeline Ops.

1. Price Trends

For any of the 124 product categories, time series of median price (blue) with IQR band (shaded) and mean price (dashed red). When median and mean diverge, a small set of expensive or cheap products is pulling the average away from what most consumers actually pay.

Streamlit Price Trends dashboard page
Fig 1 · Price Trends page — median + IQR + mean for one selected category, plus summary metrics below.

2. Price vs. CPI — the "so what?" page

Dual-axis overlay of actual median product price (left, KRW) against the official CPI index for that category (right, base year 2020=100). The callout below calculates "Product prices changed +X% while CPI changed +Y%." When product prices outrun the CPI, that's a category where official inflation may be understating real consumer cost increases — or where e-commerce pricing simply differs from the broader market the CPI measures.

Streamlit Price vs. CPI dashboard page
Fig 2 · Price vs. CPI — the original question made visible.

3. Data Quality Health

6 quality checks rendered as color-coded cards (green pass / yellow warn / red fail), historical line chart for gradual degradation, and a flagged-anomalies table from the IQR-based detector.

Streamlit Data Quality Health dashboard page
Fig 3 · Data Quality Health — current check status + trend + anomalies table.

4. Pipeline Ops

Operational monitoring: data freshness indicators, a per-run scatter plot of every collection (color = success/failure, size = record count), records-per-run bar chart, and full log table.

Streamlit Pipeline Ops dashboard page
Fig 4 · Pipeline Ops — freshness, run history, and error logs in one place.
143 → 0
false-positive CPI alerts after threshold recalibration
8
Architecture Decision Records
23
unit + integration tests passing

Limitations

What this pipeline doesn't do, explicitly.

  • Not truly real-time. KOSTAT data has ~2-week lag despite the project name. Weekly collection is the best possible cadence.
  • Single-machine deployment. No HA, no cloud infra. Designed as a portfolio piece, not a production service. AWS cost estimate (~$35–50/month) is in docs/cost-analysis.md but not committed work.
  • No PPI / wholesale data yet. ECOS exposes PPI stat codes 404Y014404Y017 that would complete the inflation story (consumer ↔ wholesale ↔ official). Documented in the roadmap but not implemented.
  • Dashboard has no auth. Streamlit runs locally with no user management — fine for a portfolio, not deployable as-is.

Lessons Learned

Thresholds need data, not theory. Every quality check I tuned by intuition first and observation second produced false positives until I rebuilt against the actual distribution.

What worked. Writing an ADR for each major decision. Overkill on a solo project, technically — but it forced me to engage with the why of each choice rather than the what, and it meant later changes were grounded in prior reasoning rather than reflex.

What didn't. I assumed government API documentation could be trusted. The KOSTAT API in particular diverges from its docs in ways that fail silently: successful responses omit the result code, item codes are 6 characters where docs say 7. A whole class of early bugs came from believing the docs over what the API actually returned. One hour spent inspecting actual API responses before writing the collector would have saved several days of debugging.

What I'd do differently. Start with a small sample of real API responses, not the docs. Specifically, build a "what does this endpoint actually return?" diagnostic step as the very first piece of the pipeline, before any business logic. That diagnostic would also be the natural place to seed the schema-drift-detection baseline.

← Prev: 8M PERM Applications Next: PheWeb Beta Matrix Builder →