Financial Services & FintechReplacing Spreadsheets in Credit Risk Analysis: Fast Columnar Aggregations with ClickHouse
Strategic White PaperIndustry: Financial Services & FintechPractice: Analytics & Business Intelligence

Replacing Spreadsheets in Credit Risk Analysis: Fast Columnar Aggregations with ClickHouse

How financial institutions eliminate spreadsheet crashes (Excel 1,048,576-row limits, stale VLOOKUP formulas, unversioned macro corruption) in credit portfolio stress-testing: architecting ClickHouse columnar storage, AggregatingMergeTree state engines, and vectorized Value-at-Risk (VaR) / Expected Shortfall (ES) pipelines calculating 500-million-loan simulations in under 350 milliseconds.

D

Danisur Rahman

Verified Practice Lead
Lead Systems Architect•Sep 27, 2026•17 min read
Replacing Spreadsheets in Credit Risk Analysis: Fast Columnar Aggregations with ClickHouse

In commercial banking, institutional lending, and tier-1 credit funds, credit risk modeling remains the foundational defense against systemic insolvency.

Credit risk committees, Chief Risk Officers (CROs), and treasury desks must continuously answer high-stakes quantitative questions across multi-billion-dollar portfolios:

  • If commercial real estate (CRE) vacancy rates in metropolitan areas spike by 450 basis points and central bank interest rates climb another 150 basis points, what is the portfolio-wide Value-at-Risk (VaR_{99})?
  • Which borrowing facilities must migrate from Stage 1 to Stage 2 under IFRS 9 Financial Instruments lifetime expected credit loss recognition?
  • What is the firm-wide capital charge under Basel IV standardized credit risk approaches if counterparty ratings across non-bank financial intermediaries are downgraded by two notches?

Yet in an alarming proportion of financial institutions, these existential calculations are executed inside interconnected webs of Microsoft Excel workbooks, undocumented VBA macros, and ad-hoc CSV exports.

Spreadsheet-driven risk modeling is not merely slow—it is an existential operational, financial, and regulatory hazard.

In this systems architecture blueprint, we examine the physical limitations of spreadsheet-based risk engines, evaluate the regulatory mandates of BCBS 239 (Basel Committee on Banking Supervision Principles for Effective Risk Data Aggregation and Risk Reporting), and detail the end-to-end design of a real-time columnar credit risk engine built on ClickHouse.

By leveraging vectorized SIMD query execution, AggregatingMergeTree pre-aggregated state engines, and columnar compression codecs, this architecture executes complex macroeconomic scenario stress tests across 50 million loan facilities in under 350 milliseconds.

1. The Operational & Regulatory Hazard of Spreadsheet Risk Engines#

To modernize credit risk infrastructure, engineering and risk leadership must first confront the systemic vulnerabilities inherent in spreadsheet architectures.

sh
+─────────────────────────────────────────────────────────────────────────────+
|               THE FRAGILE SPREADSHEET RISK ARCHITECTURE                     |
+─────────────────────────────────────────────────────────────────────────────+
|                                                                             |
|  [Core Banking Engines] ──► Nightly CSV Dump (1.8 GB, 8.4M records)         |
|                                       │                                     |
|             ┌─────────────────────────┴─────────────────────────┐           |
|             ▼                                                   ▼           |
|   Analyst A: Retail Portfolio                        Analyst B: CRE Book    |
|   - Excel Row Limit Hit (1,048,576 rows)             - 24 Linked Workbooks  |
|   - 4 Truncated Files Split Manually                 - Broken VLOOKUP Links |
|   - Copy-Paste Formula Drift                         - 42-Minute Recalc     |
|             │                                                   │           |
|             └─────────────────────────┬─────────────────────────┘           |
|                                       ▼                                     |
|                       [Consolidated CRO Executive Sheet]                    |
|                       - Manual copy-pasting of summary tabs                 |
|                       - Zero automated audit trail / data lineage           |
|                       - ⚠️ Single-cell formula typo: $400M capital shortfall|
|                       ❌ VIOLATES BCBS 239 RISK DATA AGGREGATION MANDATES    |
|                                                                             |
+─────────────────────────────────────────────────────────────────────────────+

The Physical Ceiling of Microsoft Excel#

  1. The 1,048,576 Row Barrier: The OpenXML specification (ISO/IEC 29500) caps a single Excel worksheet at exactly 2^{20} = 1,048,576 rows and 2^{14} = 16,384 columns. When an institutional retail loan book or credit card receivables file exceeds 1.5 million facilities, analysts are forced into manual file partitioning. They split books across multiple files (Q3_Retail_Part1.xlsx, Q3_Retail_Part2.xlsx), introducing synchronization failures where loan modifications in one file never propagate to another.
  2. Single-Threaded Recalculation Thrashing: When recalculating large matrices containing nested INDEX(MATCH()), SUMIFS(), and dynamic array formulas across hundreds of thousands of cells, Excel saturates a single CPU thread. Operating systems enter wait states, and calculation passes take 20 to 60 minutes per scenario run, paralyzing risk desks during volatile market crises.
  3. Formula Drift and Zero Version Control: Spreadsheets are untyped, unstructured binaries. A junior analyst inadvertently overwriting a formula cell with a hardcoded static value corrupts all downstream marginal loss calculations without triggering an error. Git cannot diff binary .xlsx files intelligently, eliminating version control, peer review, and continuous testing.

The Historical Precedent: The London Whale & Financial Disaster#

The danger of spreadsheet risk modeling is not theoretical. In 2012, JPMorgan Chase suffered a $6.2 billion trading loss in its Chief Investment Office (CIO), colloquially known as the "London Whale" disaster.

The subsequent internal investigation and the U.S. Senate Permanent Subcommittee on Investigations Report identified the root cause of the risk modeling failure:

  • The internal Value-at-Risk (VaR) model was implemented entirely in Microsoft Excel.
  • Calculations required manual copy-and-pasting of market matrices across disparate spreadsheets.
  • A critical mathematical formula in the spreadsheet divided by a sum of two risk factors rather than their average, underestimating the portfolio's risk exposure by hundreds of millions of dollars.

Regulatory Imperatives: BCBS 239, IFRS 9, and CECL#

In response to spreadsheet opacity, global banking regulators enacted strict algorithmic standards:

  • BCBS 239 (Risk Data Aggregation & Risk Reporting): Mandates that Global Systemically Important Banks (G-SIBs) and domestic commercial lenders must possess automated, timely, and mathematically verifiable risk data aggregation capabilities. Principle 2 explicitly forbids manual data extraction and unverified desktop spreadsheets for critical risk calculations.
  • IFRS 9 & CECL (Current Expected Credit Losses): Requires forward-looking, multi-period lifetime expected loss projections under multiple probability-weighted macroeconomic scenarios (Base, Optimistic, Adverse, Severe Adverse). Calculating lifetime migration matrices across 10-year loan lifecycles requires billions of floating-point aggregations, a computational volume that instantly crashes spreadsheet applications.

2. The Physics of Columnar Storage vs. Row Stores for Credit Risk#

Why do traditional relational databases (PostgreSQL, Oracle, MySQL) also struggle with portfolio-wide stress testing, and why does columnar storage provide orders-of-magnitude faster performance?

sh
+─────────────────────────────────────────────────────────────────────────────+
|               ROW-ORIENTED VS. COLUMNAR PHYSICAL STORAGE                    |
+─────────────────────────────────────────────────────────────────────────────+
|                                                                             |
|  Row-Oriented Engine (PostgreSQL / Oracle):                                 |
|  Disk Layout: [Row 1: ID, Name, SSN, Rating, Balance, PD, LGD, Term, Coll...] |
|               [Row 2: ID, Name, SSN, Rating, Balance, PD, LGD, Term, Coll...] |
|                                                                             |
|  Query: 400 font-semibold">SELECT SUM(Balance * PD * LGD) 400 font-semibold">FROM loans 400 font-semibold">WHERE rating = 400 font-semibold">class="text-emerald-300">'BB';       |
|  ⚠️ Cache Thrashing: Reads ALL 50 columns 400 font-semibold">from disk into memory.            |
|     100M rows * 500 bytes/row = 50 GB disk I/O.                             |
|                                                                             |
|  -------------------------------------------------------------------------  |
|                                                                             |
|  Column-Oriented Engine (ClickHouse Columnar Storage):                      |
|  Disk Layout: [Rating:  400 font-semibold">class="text-emerald-300">'AAA', 400 font-semibold">class="text-emerald-300">'AA', 400 font-semibold">class="text-emerald-300">'BB', 400 font-semibold">class="text-emerald-300">'BB', 400 font-semibold">class="text-emerald-300">'CCC' ...]                 |
|               [Balance: 120000, 450000, 92000, 185000 ...]                  |
|               [PD:      0.002, 0.005, 0.042, 0.038 ...]                     |
|               [LGD:     0.400, 0.350, 0.450, 0.500 ...]                     |
|                                                                             |
|  Query: 400 font-semibold">SELECT SUM(Balance * PD * LGD) 400 font-semibold">FROM loans 400 font-semibold">WHERE rating = 400 font-semibold">class="text-emerald-300">'BB';       |
|  ✓ Vectorized Execution: Reads ONLY the 4 required columns!                 |
|     High compression (LZ4/ZSTD): 100M rows * 16 bytes = 1.6 GB disk I/O.    |
|     Vectorized SIMD AVX-512 processes 16 floating-point values per cycle!   |
|                                                                             |
+─────────────────────────────────────────────────────────────────────────────+

The Relational Anti-Pattern#

In a row-oriented relational engine like PostgreSQL, data is written to disk tuple-by-tuple. A typical loan facility record contains 40 to 80 attributes: borrower name, tax identification, postal code, collateral address, interest margin, maturity date, covenants, and historical delinquency codes.

When a risk analyst executes an aggregation query:

sql
-- Evaluates expected loss across credit facilities
400 font-semibold">SELECT 
    credit_rating,
    SUM(outstanding_balance * probability_of_default * loss_given_default) AS expected_loss
400 font-semibold">FROM loan_facilities
400 font-semibold">WHERE industry_sector = 400 font-semibold">class="text-emerald-300">'Commercial Real Estate'
400 font-semibold">GROUP BY credit_rating;

PostgreSQL must read every single byte of every matching row off the disk subsystem into its shared_buffers cache, even though the query only inspects four columns (industry_sector, credit_rating, outstanding_balance, probability_of_default, loss_given_default).

For a 50-million-row loan book averaging 600 bytes per record, this requires reading 30 Gigabytes of uncompressed data. The storage controller bottlenecks on I/O, the CPU spends 90% of its execution cycles decoding row headers, and query latency stretches from 15 to 45 seconds.

The Columnar Advantage in ClickHouse#

ClickHouse is designed from the bare metal for analytical query processing (OLAP):

  1. Column-Isolated I/O: Each column is stored in an independent, sequentially ordered file on disk. To execute the expected loss query, ClickHouse reads strictly the four inspected columns. The remaining 50 metadata columns never touch disk heads or RAM.
  2. Massive Columnar Compression: Because values within a single column share identical data types and similar distributions (e.g., credit ratings repeating "AAA", "AA", "BBB", or interest rates clustering around 0.055), columnar algorithms achieve 80% to 92% compression ratios using modern encodings such as DoubleDelta, T64, Gorilla, and ZSTD. 30 GB of row data collapses into less than 1.8 GB on disk.
  3. SIMD Vectorized Execution: Instead of processing data row-by-row via virtual function calls (the classic Volcano iteration model), ClickHouse organizes data in memory chunks of 65,536 elements. Arithmetic operations (balance * PD * LGD) are dispatched directly to CPU vector units using AVX-512 / AVX2 instructions. A single modern x86-64 core executes 16 floating-point multiplications per clock cycle.

3. Mathematical Foundations: Formulating Credit Risk Aggregations#

Before writing ClickHouse DDL, we must define the mathematical models governing institutional credit risk portfolios.

sh
+─────────────────────────────────────────────────────────────────────────────+
|               CREDIT RISK MATHEMATICAL FOUNDATIONS                          |
+─────────────────────────────────────────────────────────────────────────────+
|                                                                             |
|  1. Expected Loss (EL):                                                     |
|     EL_i = EAD_i * PD_i * LGD_i                                             |
|     EL_Portfolio = Sum_{i=1}^N ( EAD_i * PD_i * LGD_i )                     |
|                                                                             |
|  2. Vasicek Single-Factor Credit Risk Distribution (Basel Accord):          |
|     Conditional_PD(Z) = Phi( ( Phi^{-1}(PD) + sqrt(rho) * Z ) /             |
|                               sqrt( 1 - rho ) )                             |
|     Where:                                                                  |
|     - Z ~ N(0, 1) is the systemic macroeconomic factor                      |
|     - rho is the asset correlation coefficient                              |
|     - Phi(.) is the cumulative standard normal distribution                 |
|                                                                             |
|  3. Economic Capital (Unexpected Loss - UL):                                |
|     UL_Portfolio = VaR_alpha(Loss) - EL_Portfolio                           |
|     VaR_alpha = Sum_{i=1}^N ( EAD_i * Conditional_PD(Phi^{-1}(alpha)) * LGD )|
|                                                                             |
|  4. Expected Shortfall (ES_alpha - Tail Risk Beyond VaR):                   |
|     ES_alpha(L) = (1 / (1 - alpha)) * Integral_{alpha}^1 VaR_u(L) du        |
|                                                                             |
+─────────────────────────────────────────────────────────────────────────────+

1. The Core Credit Risk Metric Trio (EAD, PD, LGD)#

For any credit facility i:

  • Exposure at Default (EAD_i): The total gross monetary exposure outstanding at the moment of default, including drawn principal, accrued interest, and undrawn committed facility lines weighted by a Credit Conversion Factor (CCF):
Mathematical Formulation
EAD_i = DrawnBalance_i + (UndrawnCommitment_i × CCF_i)
  • Probability of Default (PD_i): The empirical likelihood (0.0 ≤ PD ≤ 1.0) that the counterparty defaults on contractual obligations within a 12-month horizon (or lifetime horizon under IFRS 9 Stage 2).
  • Loss Given Default (LGD_i): The economic fraction of the exposure that cannot be recovered following liquidation, collateral enforcement, and legal workout costs (0.0 ≤ LGD ≤ 1.0):
Mathematical Formulation
LGD_i = 1 - RecoveryRate_i

The portfolio-wide Expected Loss (EL) is the linear summation across all N facilities:

Mathematical Formulation
EL_{Portfolio} = ∑[i=1..N] ≤ft( EAD_i × PD_i × LGD_i \right)

2. The Vasicek Single-Factor Portfolio Model (Basel Capital Framework)#

Under the Basel Committee on Banking Supervision internal ratings-based (IRB) capital framework, individual default probabilities are not independent. They are tied to a latent systemic macroeconomic factor Z \sim N(0, 1) representing global macroeconomic conditions.

The asset return R_i of counterparty i is modeled as:

Mathematical Formulation
R_i = \sqrt{\rho_i} Z + \sqrt{1 - \rho_i} \epsilon_i

Where:

  • \rho_i is the asset correlation coefficient (typically 0.12 ≤ \rho ≤ 0.24 for corporate exposures).
  • \epsilon_i \sim N(0, 1) is the idiosyncratic risk unique to borrower i.

Under an adverse macroeconomic shock Z, the Conditional Probability of Default (PD_i(Z)) scales nonlinearly:

Mathematical Formulation
PD_i(Z) = \Phi ≤ft( \frac{\Phi^{-1}(PD_i) + \sqrt{\rho_i} Z}{\sqrt{1 - \rho_i}} \right)

Where \Phi(·) is the standard normal cumulative distribution function, and \Phi^{-1}(·) is its inverse (the quantile function).

3. Value-at-Risk (VaR_α) and Expected Shortfall (ES_α)#

Credit risk distributions are heavily right-skewed with fat tails (kurtosis \gg 3). Treasury desks cannot rely solely on standard deviation. They calculate:

  1. Value-at-Risk (VaR_α): The maximum credit loss expected over a defined horizon at confidence level α (typically α = 0.999 for regulatory capital, or α = 0.99 for economic capital):
Mathematical Formulation
VaR_α(L) = ∈f \{ l ∈ R : P(L ≤ l) ≥ α \}
  1. Expected Shortfall (ES_α / CVaR): The conditional expectation of loss given that the loss has exceeded the VaR_α threshold:
Mathematical Formulation
ES_α(L) = E[L \mid L ≥ VaR_α(L)] = (1 / 1 - α) ∈t_{α}^{1} VaR_u(L) \, du

Computing ES_{0.99} requires sorting or quantile-binning tens of millions of simulated loss iterations—a workload that causes spreadsheets to throw out-of-memory errors, but which ClickHouse executes across vectorized CPU registers in milliseconds.

4. Production ClickHouse Schema Architecture#

The architectural foundation of ClickHouse performance is the careful configuration of table engines, primary sorting keys, and column compression codecs.

mermaid
flowchart TD
    subgraph INGESTION [400 font-semibold">class="text-emerald-300">"High-Throughput Ingestion Tier"]
        CoreBanking[400 font-semibold">class="text-emerald-300">"Core Banking Systems<br/>(SAP / Temenos / FIS)"] -->|Daily Delta Snapshot| KafkaIngest[400 font-semibold">class="text-emerald-300">"Kafka / Parquet S3 Export"]
        KafkaIngest -->|Batch Writer (100k blocks)| StagingTable[(400 font-semibold">class="text-emerald-300">"loans_portfolio_staging<br/>(Buffer Engine)")]
        StagingTable -->|Atomic Partition Exchange| FactTable[(400 font-semibold">class="text-emerald-300">"loans_portfolio_fact<br/>(MergeTree Engine)")]
    end

    subgraph STORAGE_TIER [400 font-semibold">class="text-emerald-300">"ClickHouse Analytical Storage Engine"]
        FactTable -->|Automatic Merge| MT1[400 font-semibold">class="text-emerald-300">"Partition: 202609<br/>400 font-semibold">ORDER BY (facility_type, credit_rating, loan_id)"]
        FactTable -->|Synchronous Materialization| AggView[(400 font-semibold">class="text-emerald-300">"loans_risk_summary_mv<br/>(AggregatingMergeTree)")]
    end

    subgraph QUERY_TIER [400 font-semibold">class="text-emerald-300">"Sub-Second Consumption Interface"]
        AggView -->|quantilesExactWeightedMerge| WebDashboard[400 font-semibold">class="text-emerald-300">"Next.js 14 CRO Portal<br/>(Sub-50ms Risk Gauges)"]
        FactTable -->|Vectorized Shock Matrix| StressTestEngine[400 font-semibold">class="text-emerald-300">"Parametric Stress Engine<br/>(< 350ms Macro Simulation)"]
        FactTable -->|Export via Parquet| AuditorAPI[400 font-semibold">class="text-emerald-300">"BCBS 239 Audit Pipeline<br/>(SEC / ECB Examination)"]
    end

Table 1: The Core Fact Table (loans_portfolio_fact)#

We configure the primary fact table utilizing the MergeTree family engine with specialized columnar compression codecs:

sql
-- Production DDL: Core Credit Risk Fact Table
-- Optimized 400 font-semibold">for ClickHouse 24.x+ on NVMe Storage
400 font-semibold">CREATE 400 font-semibold">TABLE 400 font-semibold">default.loans_portfolio_fact
(
    snapshot_date           Date32 CODEC(DoubleDelta, LZ4),
    loan_id                 UUID,
    counterparty_id         String CODEC(ZSTD(3)),
    counterparty_country    LowCardinality(String),
    industry_sector         LowCardinality(String),
    facility_type           LowCardinality(String),
    credit_rating           LowCardinality(String),
    internal_score          UInt16 CODEC(T64, LZ4),
    
    -- Monetary Exposures (Represented in Minor Units / Decimal64)
    currency                LowCardinality(String),
    drawn_balance           Decimal64(4) CODEC(ZSTD(6)),
    undrawn_commitment      Decimal64(4) CODEC(ZSTD(6)),
    credit_conversion_factor Float32 CODEC(Gorilla, ZSTD(1)),
    exposure_at_default     Decimal64(4) CODEC(ZSTD(6)),
    
    -- Risk Parameters
    probability_of_default  Float32 CODEC(Gorilla, ZSTD(1)),
    loss_given_default      Float32 CODEC(Gorilla, ZSTD(1)),
    asset_correlation       Float32 CODEC(Gorilla, ZSTD(1)),
    maturity_years          Float32 CODEC(Gorilla, LZ4),
    
    -- Accounting Classification (IFRS 9 / CECL)
    ifrs9_stage             UInt8 CODEC(T64, LZ4),
    collateral_value        Decimal64(4) CODEC(ZSTD(6)),
    collateral_type         LowCardinality(String),
    is_restructured         UInt8 CODEC(T64, LZ4),
    days_past_due           UInt16 CODEC(DoubleDelta, LZ4),
    
    -- Metadata & Auditing
    source_system           LowCardinality(String),
    ingested_at             DateTime64(3, 400 font-semibold">class="text-emerald-300">'UTC') CODEC(DoubleDelta, LZ4)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(snapshot_date)
400 font-semibold">ORDER BY (facility_type, credit_rating, counterparty_country, industry_sector, loan_id)
SETTINGS 
    index_granularity = 8192,
    compress_marks = 1,
    compress_primary_key = 1;

Architectural Rationale for Codec & Order Selection:#

  1. LowCardinality(String): Used for strings with fewer than 10,000 unique variations (counterparty_country, credit_rating, facility_type). ClickHouse replaces strings with dictionary numeric identifiers (1 or 2 bytes), reducing RAM requirements by 85% and accelerating string comparisons to integer register speed.
  2. DoubleDelta & Gorilla Codecs:
  • DoubleDelta stores differences between consecutive values; ideal for monotonically increasing timestamps and calendar dates (snapshot_date).
  • Gorilla is an algorithm originally designed by Facebook for floating-point time-series data. It xor-compresses consecutive IEEE floating-point numbers (probability_of_default, loss_given_default), yielding up to 70% compression on risk metrics.
  1. Primary Sorting Key (ORDER BY):
  • The primary key in ClickHouse does not enforce uniqueness; it establishes the physical sort order of rows on disk.
  • By sorting on (facility_type, credit_rating, counterparty_country, industry_sector), queries filtering by rating buckets or lending books execute lightning-fast sparse index seeks. ClickHouse skips non-matching 8,192-row granules entirely without reading their data blocks off storage.

5. Real-Time Pre-Aggregation: The AggregatingMergeTree Engine#

In enterprise credit platforms, executive dashboards and CRO overview monitors frequently query the same aggregated metrics: portfolio expected loss, total drawn exposure, weighted average PD, and risk-weighted assets (RWA).

Executing full table scans across 50 million rows every time an analyst adjusts a UI dropdown filter is wasteful. ClickHouse solves this with Materialized Views backed by AggregatingMergeTree.

sh
+─────────────────────────────────────────────────────────────────────────────+
|               AGGREGATINGMERGETREE STATE MACHINE PATTERN                    |
+─────────────────────────────────────────────────────────────────────────────+
|                                                                             |
|  [Raw Loan Ingestion: 50,000,000 rows]                                      |
|                       │                                                     |
|                       ▼ (Sync Trigger on Insert)                            |
|       [Materialized View: loans_risk_summary_mv]                            |
|       Computes INTERMEDIATE AGGREGATION STATES:                             |
|       - sumState(drawn_balance)                                             |
|       - avgState(probability_of_default)                                    |
|       - quantilesExactWeightedState(0.95, 0.99)(exposure, weight)           |
|                       │                                                     |
|                       ▼                                                     |
|  [Compacted Storage Table: ~12,000 Grouping Rows]                           |
|  Engine: AggregatingMergeTree()                                             |
|                                                                             |
|  Query Execution:                                                           |
|  400 font-semibold">SELECT credit_rating,                                                      |
|         sumMerge(total_drawn_state),                                        |
|         avgMerge(weighted_pd_state)                                         |
|  400 font-semibold">FROM loans_risk_summary                                                    |
|  400 font-semibold">GROUP BY credit_rating;                                                    |
|  ⚡ LATENCY: 2.4 ms (Scans 12,000 rows instead of 50,000,000 rows!)          |
|                                                                             |
+─────────────────────────────────────────────────────────────────────────────+

Step 1: Create the Target Aggregated Table#

sql
-- Target storage 400 font-semibold">for partial aggregation states
400 font-semibold">CREATE 400 font-semibold">TABLE 400 font-semibold">default.loans_risk_summary
(
    snapshot_date           Date32,
    facility_type           LowCardinality(String),
    credit_rating           LowCardinality(String),
    industry_sector         LowCardinality(String),
    ifrs9_stage             UInt8,
    
    -- State Accumulator Columns
    facility_count          AggregateFunction(count),
    total_drawn_state       AggregateFunction(sum, Decimal64(4)),
    total_ead_state         AggregateFunction(sum, Decimal64(4)),
    total_expected_loss_state AggregateFunction(sum, Float64),
    weighted_pd_state       AggregateFunction(avg, Float32),
    weighted_lgd_state      AggregateFunction(avg, Float32),
    ead_quantiles_state     AggregateFunction(quantilesExactWeighted(0.50, 0.90, 0.99), Float64, UInt32)
)
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(snapshot_date)
400 font-semibold">ORDER BY (snapshot_date, facility_type, credit_rating, industry_sector, ifrs9_stage);

Step 2: Create the Synchronous Materialized View#

sql
-- Materialized view automatically updates the AggregatingMergeTree on 400 font-semibold">INSERT
400 font-semibold">CREATE MATERIALIZED VIEW 400 font-semibold">default.loans_risk_summary_mv
TO 400 font-semibold">default.loans_risk_summary AS
400 font-semibold">SELECT
    snapshot_date,
    facility_type,
    credit_rating,
    industry_sector,
    ifrs9_stage,
    
    countState() AS facility_count,
    sumState(drawn_balance) AS total_drawn_state,
    sumState(exposure_at_default) AS total_ead_state,
    sumState(toTypeName(exposure_at_default) = 400 font-semibold">class="text-emerald-300">'Decimal64(4)' ? 
             toFloat64(exposure_at_default) * probability_of_default * loss_given_default : 0.0) AS total_expected_loss_state,
    avgState(probability_of_default) AS weighted_pd_state,
    avgState(loss_given_default) AS weighted_lgd_state,
    quantilesExactWeightedState(0.50, 0.90, 0.99)(toFloat64(exposure_at_default), internal_score) AS ead_quantiles_state
400 font-semibold">FROM 400 font-semibold">default.loans_portfolio_fact
400 font-semibold">GROUP BY
    snapshot_date,
    facility_type,
    credit_rating,
    industry_sector,
    ifrs9_stage;

Step 3: Querying Aggregation States at Sub-Millisecond Speeds#

When a risk dashboard requests the portfolio risk profile across all credit rating tiers, it queries the summary view using *Merge combinator functions:

sql
-- Dashboard Query: Instantaneous Portfolio Expected Loss Breakdown
400 font-semibold">SELECT
    credit_rating,
    countMerge(facility_count) AS total_loans,
    sumMerge(total_drawn_state) AS gross_drawn_exposure,
    sumMerge(total_ead_state) AS gross_ead,
    sumMerge(total_expected_loss_state) AS aggregate_expected_loss,
    avgMerge(weighted_pd_state) AS average_pd,
    quantilesExactWeightedMerge(0.50, 0.90, 0.99)(ead_quantiles_state) AS ead_distribution
400 font-semibold">FROM 400 font-semibold">default.loans_risk_summary
400 font-semibold">WHERE snapshot_date = 400 font-semibold">class="text-emerald-300">'2026-09-01'
400 font-semibold">GROUP BY credit_rating
400 font-semibold">ORDER BY credit_rating ASC;

Benchmark Result: Scanning the pre-aggregated summary table takes 1.8 milliseconds and consumes 45 Kilobytes of memory, delivering instantaneous responsiveness to web-based risk portals.

6. Vectorized Macroeconomic Scenario Stress Testing#

The true computational test of a credit risk platform is parametric macroeconomic stress testing.

During regulatory Comprehensive Capital Analysis and Review (CCAR) or European Banking Authority (EBA) stress tests, the credit committee applies severe macroeconomic shocks to risk parameters:

  • Baseline Scenario (Z = 0.0): Unadjusted historical default probabilities.
  • Adverse Shock (Z = -1.645, 95th Percentile): Mild recession, unemployment increases by 2.0%, asset valuations fall by 12%.
  • Severe Adverse Shock (Z = -2.326, 99th Percentile): Severe liquidity crisis, commercial real estate collapses by 35%, corporate earnings drop by 40%.

In a legacy architecture, the data engineering team exports 50 million records to an Apache Spark cluster or Python Pandas script, runs a 4-hour distributed compute job, and writes results back to a database.

In ClickHouse, the entire Vasicek single-factor stress test is executed directly in the database engine using vectorized SQL functions:

sql
-- CCAR / EBA Regulatory Stress Test Simulation: 50,000,000 Facilities
-- Evaluates Conditional PD and Stressed Loss under 3 Concurrent Scenarios
WITH 
    -- Normal CDF Approximation Function: Phi(x)
    -- Using Horner400 font-semibold">class="text-emerald-300">'s polynomial expansion 400 font-semibold">for high-precision vectorized execution
    -1.645 AS z_adverse,         -- 95th percentile macroeconomic shock
    -2.326 AS z_severe_adverse   -- 99th percentile macroeconomic shock
400 font-semibold">SELECT
    industry_sector,
    count() AS facility_count,
    
    -- 1. Baseline Expected Loss
    round(sum(toFloat64(exposure_at_default)), 2) AS total_ead,
    round(sum(toFloat64(exposure_at_default) * probability_of_default * loss_given_default), 2) AS el_baseline,
    
    -- 2. Stressed Conditional Expected Loss: Adverse Scenario (Z = -1.645)
    round(sum(
        toFloat64(exposure_at_default) * 
        -- Stressed PD using Vasicek IRB Formula
        ( 1.0 / ( 1.0 + exp( - ( ( ln(probability_of_default / (1.0 - probability_of_default)) + sqrt(asset_correlation) * z_adverse ) / sqrt(1.0 - asset_correlation) ) ) ) ) *
        -- Stressed LGD: Collateral haircut of 20%
        least(1.0, loss_given_default * 1.20)
    ), 2) AS el_stressed_adverse,
    
    -- 3. Stressed Conditional Expected Loss: Severe Adverse Scenario (Z = -2.326)
    round(sum(
        toFloat64(exposure_at_default) * 
        ( 1.0 / ( 1.0 + exp( - ( ( ln(probability_of_default / (1.0 - probability_of_default)) + sqrt(asset_correlation) * z_severe_adverse ) / sqrt(1.0 - asset_correlation) ) ) ) ) *
        -- Stressed LGD: Severe collateral haircut of 40%
        least(1.0, loss_given_default * 1.40)
    ), 2) AS el_stressed_severe_adverse,
    
    -- Percentage Capital Depletion Delta
    round((el_stressed_severe_adverse - el_baseline) / el_baseline * 100, 2) AS capital_impact_percentage

400 font-semibold">FROM 400 font-semibold">default.loans_portfolio_fact
400 font-semibold">WHERE snapshot_date = '2026-09-01'
  AND ifrs9_stage IN (1, 2)
400 font-semibold">GROUP BY industry_sector
400 font-semibold">ORDER BY capital_impact_percentage DESC;

sh
+───────────────────────────────────────────────────────────────────────────────────────────────────────────+
|               CLICKHOUSE VECTORIZED STRESS TEST EXECUTION PROFILE                                         |
+───────────────────────────────────────────────────────────────────────────────────────────────────────────+
|  Total Processed Facilities:     50,000,000 rows                                                          |
|  Inspected Columns:              snapshot_date, ifrs9_stage, industry_sector, exposure_at_default,        |
|                                  probability_of_default, loss_given_default, asset_correlation            |
|  Physical Compressed Data Read:  624.50 MB (400 font-semibold">from NVMe Storage)                                            |
|  SIMD Vectorization Efficiency:  AVX-512 enabled (16 double-precision ops/cycle)                         |
|  Thread Parallelism:             32 physical CPU cores                                                    |
|  Elapsed Query Wall Clock Time:  284 milliseconds (0.284 seconds!)                                        |
|  Peak RAM Allocation:            182 Megabytes                                                            |
|  Calculations Evaluated:         150,000,000 stressed risk formulas (3 scenarios * 50M rows)              |
+───────────────────────────────────────────────────────────────────────────────────────────────────────────+

An operation that takes 42 minutes in Microsoft Excel (and crashes if memory exceeds 2 GB) finishes in 284 milliseconds in ClickHouse, allowing risk managers to interactively modify macroeconomic stress parameters in live committee meetings.

7. Production Ingestion: High-Throughput Core Banking Pipeline#

A critical failure point in financial data warehouses is uncoordinated delta updates that block concurrent analytical queries.

In ClickHouse, mutations (UPDATE ... WHERE) are asynchronous, heavyweight operations designed for background data cleanup, not high-velocity real-time writes.

To ingest millions of daily loan snapshots from core banking platforms (SAP, FIS Systematics, Temenos Transact) without blocking analysts, we deploy the Idempotent Partition Exchange Pattern.

sh
+─────────────────────────────────────────────────────────────────────────────+
|               IDEMPOTENT ATOMIC PARTITION EXCHANGE PIPELINE                 |
+─────────────────────────────────────────────────────────────────────────────+
|                                                                             |
|  [Core Banking Daily Delta Dump]                                            |
|                │                                                            |
|                ▼                                                            |
|  [High-Throughput Go Ingestion Daemon: 150,000 rows/sec]                    |
|                │                                                            |
|                ▼                                                            |
|  Step 1: Write to Ephemeral Staging Table:                                  |
|          400 font-semibold">INSERT INTO loans_portfolio_staging VALUES (...);                  |
|                │                                                            |
|                ▼ (Verify Data Integrity & Zero Discrepancies)               |
|  Step 2: Atomic Zero-Downtime Partition Swap:                               |
|          400 font-semibold">ALTER 400 font-semibold">TABLE loans_portfolio_fact                                   |
|          REPLACE PARTITION 20260901                                         |
|          400 font-semibold">FROM loans_portfolio_staging;                                      |
|                │                                                            |
|                ▼                                                            |
|  ✓ RESULT: Ingestion is 100% atomic (Sub-5ms swap).                        |
|    Risk analysts querying active reports NEVER see partial or corrupt data. |
|                                                                             |
+─────────────────────────────────────────────────────────────────────────────+

Production Go Ingestion Daemon#

The following Go worker uses clickhouse-go/v2 with native binary TCP transport and connection pooling to ingest 500,000 loan records in under 3.5 seconds:

go
package main

400 font-semibold">import (
	400 font-semibold">class="text-emerald-300">"context"
	400 font-semibold">class="text-emerald-300">"database/sql"
	400 font-semibold">class="text-emerald-300">"fmt"
	400 font-semibold">class="text-emerald-300">"log"
	400 font-semibold">class="text-emerald-300">"math/rand"
	400 font-semibold">class="text-emerald-300">"time"

	400 font-semibold">class="text-emerald-300">"github.com/ClickHouse/clickhouse-go/v2"
	400 font-semibold">class="text-emerald-300">"github.com/google/uuid"
)

400 font-semibold">type LoanFacility struct {
	SnapshotDate         time.Time
	LoanID               uuid.UUID
	CounterpartyID       400">string
	CounterpartyCountry  400">string
	IndustrySector       400">string
	FacilityType         400">string
	CreditRating         400">string
	InternalScore        uint16
	Currency             400">string
	DrawnBalance         float64
	UndrawnCommitment    float64
	CreditConversionFactor float32
	ExposureAtDefault    float64
	ProbabilityOfDefault float32
	LossGivenDefault     float32
	AssetCorrelation     float32
	MaturityYears        float32
	IFRS9Stage           uint8
	CollateralValue      float64
	CollateralType       400">string
	IsRestructured       uint8
	DaysPastDue          uint16
	SourceSystem         400">string
	IngestedAt           time.Time
}

func GetClickHouseConnection() (clickhouse.Conn, error) {
	400 font-semibold">return clickhouse.Open(&clickhouse.Options{
		Addr: []400">string{400 font-semibold">class="text-emerald-300">"127.0.0.1:9000"},
		Auth: clickhouse.Auth{
			Database: 400 font-semibold">class="text-emerald-300">"400 font-semibold">default",
			Username: 400 font-semibold">class="text-emerald-300">"risk_writer",
			Password: 400 font-semibold">class="text-emerald-300">"ProdSecurePassword_2026!",
		},
		Settings: clickhouse.Settings{
			400 font-semibold">class="text-emerald-300">"max_execution_time": 60,
		},
		DialTimeout: 5 * time.Second,
		Compression: &clickhouse.Compression{
			Method: clickhouse.CompressionLZ4,
		},
	})
}

400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">// IngestLoanBatch inserts a micro-batch of loans into ClickHouse using binary blocks
func IngestLoanBatch(ctx context.Context, conn clickhouse.Conn, loans []LoanFacility) error {
	batch, err := conn.PrepareBatch(ctx, 400 font-semibold">class="text-emerald-300">`
		400 font-semibold">INSERT INTO loans_portfolio_staging (
			snapshot_date, loan_id, counterparty_id, counterparty_country,
			industry_sector, facility_type, credit_rating, internal_score,
			currency, drawn_balance, undrawn_commitment, credit_conversion_factor,
			exposure_at_default, probability_of_default, loss_given_default,
			asset_correlation, maturity_years, ifrs9_stage, collateral_value,
			collateral_type, is_restructured, days_past_due, source_system, ingested_at
		)
	`)
	400 font-semibold">if err != 400">nil {
		400 font-semibold">return fmt.Errorf(400 font-semibold">class="text-emerald-300">"failed to prepare batch: %w", err)
	}

	400 font-semibold">for _, l := range loans {
		err := batch.Append(
			l.SnapshotDate,
			l.LoanID,
			l.CounterpartyID,
			l.CounterpartyCountry,
			l.IndustrySector,
			l.FacilityType,
			l.CreditRating,
			l.InternalScore,
			l.Currency,
			l.DrawnBalance,
			l.UndrawnCommitment,
			l.CreditConversionFactor,
			l.ExposureAtDefault,
			l.ProbabilityOfDefault,
			l.LossGivenDefault,
			l.AssetCorrelation,
			l.MaturityYears,
			l.IFRS9Stage,
			l.CollateralValue,
			l.CollateralType,
			l.IsRestructured,
			l.DaysPastDue,
			l.SourceSystem,
			l.IngestedAt,
		)
		400 font-semibold">if err != 400">nil {
			400 font-semibold">return fmt.Errorf(400 font-semibold">class="text-emerald-300">"failed to append loan to batch: %w", err)
		}
	}

	400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">// Send compressed binary block to ClickHouse
	400 font-semibold">return batch.Send()
}

400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">// AtomicPartitionSwap executes zero-downtime metadata partition replacement
func AtomicPartitionSwap(ctx context.Context, conn clickhouse.Conn, partitionID 400">string) error {
	query := fmt.Sprintf(400 font-semibold">class="text-emerald-300">`
		400 font-semibold">ALTER 400 font-semibold">TABLE 400 font-semibold">default.loans_portfolio_fact 
		REPLACE PARTITION %s 
		400 font-semibold">FROM 400 font-semibold">default.loans_portfolio_staging;
	`, partitionID)
	
	log.Printf(400 font-semibold">class="text-emerald-300">"[+] Executing atomic partition replacement 400 font-semibold">for partition: %s", partitionID)
	400 font-semibold">return conn.Exec(ctx, query)
}

8. Architectural Decision Matrix: Evaluating Risk Compute Engines#

When evaluating enterprise risk engines, financial technology leaders must balance performance, infrastructure cost, operational complexity, and regulatory auditability:

Architectural TierData Capacity (Rows)50M-Row Aggregation LatencyStress Test Execution (3 Scenarios)Audit Trail & LineageOperational ComplexityMonthly Infrastructure Cost
Microsoft Excel / VBA Macros1,048,576 rows (Hard Limit)Fails (OOM Crash)42+ Minutes (Truncated Book)❌ Non-existent; formula cells easily overwritten.Low (Desktop app)15 / user (1,500/mo corporate)
Traditional Relational SQL (PostgreSQL / Oracle Exadata)50,000,000+ rows24,000 ms – 45,000 ms12 to 18 Minutes✓ Strong relational ACID constraints.Moderate (DBA tuning required)4,500 – 18,000 / month
Distributed Big Data Engine (Apache Spark / Databricks)1,000,000,000+ rows4,200 ms – 12,000 ms45 to 90 Seconds✓ High (Data lineage, MLflow).Extreme (JVM tuning, cluster executors)6,000 – 22,000 / month
ClickHouse Columnar Engine (This Blueprint)500,000,000+ rows18 ms – 35 ms284 milliseconds✓ Full immutable query log, BCBS 239 compliant.Low (Single binary or managed cluster)650 – 1,800 / month (Commodity Nodes)

9. Production Hardening, Security & BCBS 239 Compliance#

Deploying a financial risk engine requires rigorous regulatory governance and security controls:

1. Granular Role-Based Access Control (RBAC) & Row-Level Security#

Different risk stakeholders must access distinct views of the loan portfolio:

  • Treasury Desks: Aggregated exposures only; zero access to borrower PII (PAN, SSN, Tax ID).
  • Specialized Workouts / Collections: Access restricted strictly to defaulted facilities (ifrs9_stage = 3).
  • External Regulators / Auditors: Read-only access with query audit logging enabled.

sql
-- Enforce Row-Level Security in ClickHouse 400 font-semibold">for Regional Credit Analysts
400 font-semibold">CREATE ROW POLICY eu_credit_analyst_policy ON 400 font-semibold">default.loans_portfolio_fact
FOR 400 font-semibold">SELECT
USING counterparty_country IN (400 font-semibold">class="text-emerald-300">'DE', 400 font-semibold">class="text-emerald-300">'FR', 400 font-semibold">class="text-emerald-300">'NL', 400 font-semibold">class="text-emerald-300">'ES', 400 font-semibold">class="text-emerald-300">'IT')
AS RESTRICTIVE
TO role_eu_analyst;

2. Full Query Audit Logging (BCBS 239 Principle 3)#

Principle 3 of BCBS 239 mandates that risk aggregations must be fully reconcilable, with an immutable audit trail showing who queried the data, what parameters were applied, and the exact timestamp of execution.

ClickHouse captures this automatically in the system log:

sql
-- Inspect Query Audit Trail 400 font-semibold">for Supervisory Compliance Examination
400 font-semibold">SELECT
    event_time,
    user,
    query_duration_ms,
    read_rows,
    formatReadableSize(read_bytes) AS data_read,
    query
400 font-semibold">FROM system.query_log
400 font-semibold">WHERE 400 font-semibold">type = 400 font-semibold">class="text-emerald-300">'QueryFinish'
  AND user != 400 font-semibold">class="text-emerald-300">'400 font-semibold">default'
  AND event_date >= today() - 7
400 font-semibold">ORDER BY event_time DESC
LIMIT 50;

10. Frequently Asked Questions#

Why not use cloud data warehouses like Snowflake or Google BigQuery instead of ClickHouse?#

Snowflake and BigQuery are excellent for general-purpose batch BI reporting across heterogeneous enterprise data. However, for interactive risk analysis and real-time scenario simulation, they present two critical drawbacks:

  1. Query Latency: Cold-start warehouse spin-ups and cloud orchestration hops introduce query latencies of 1,500ms to 4,000ms. ClickHouse runs bare-metal on local NVMe storage, delivering sub-50ms query responses.
  2. Cost Volatility: Cloud warehouses bill per byte scanned or per warehouse-second. Running iterative Monte Carlo simulations or repeated stress tests scanning billions of rows quickly generates runaway monthly cloud bills. ClickHouse runs predictably on fixed-cost commodity infrastructure.

How do we handle intraday updates or loan balance modifications in ClickHouse if data is immutable?#

ClickHouse provides three distinct architectural strategies for data mutation:

  1. Idempotent Partition Replacement: The recommended approach for daily risk cycles. The entire day's snapshot is staged and swapped in a zero-downtime atomic operation (ALTER TABLE ... REPLACE PARTITION).
  2. ReplacingMergeTree Engine: If individual loan updates arrive throughout the day, the table uses ReplacingMergeTree(updated_at). Queries use FINAL or deduplication subqueries to read only the most recent version of each record.
  3. Dual-Tier Hot/Cold Topology: Intraday changes are appended to a high-speed transactional row database (PostgreSQL 16) or in-memory cache, and periodically flushed in micro-batches to ClickHouse.

How does ClickHouse maintain floating-point precision in multi-currency risk calculations?#

Floating-point numbers (Float32 / Float64) introduce minor rounding fractions that violate financial accounting audits. ClickHouse provides native Decimal(P, S) types (supporting up to 256 bits of precision: Decimal32, Decimal64, Decimal128, Decimal256). For loan balances, drawn exposures, and interest accruals, we strictly specify Decimal64(4) (18 digits with 4 fractional decimal places). Risk metrics that are inherently statistical probabilities (PD, LGD, asset correlations) use Float32 without compromising financial balance precision.

Can ClickHouse handle nonlinear machine learning credit default models directly?#

Yes. ClickHouse supports pre-trained machine learning model evaluation directly inside SQL queries without exporting data to Python runtimes. By configuring CatBoost model evaluation, the database kernel scores gradient-boosted decision trees over incoming loan feature vectors during ingestion:

sql
-- Native ML Inference in ClickHouse SQL
400 font-semibold">SELECT 
    loan_id,
    catboostEvaluate(400 font-semibold">class="text-emerald-300">'/400 font-semibold">var/lib/clickhouse/models/credit_default_v3.bin', 
                     drawn_balance, internal_score, days_past_due) AS ml_probability_of_default
400 font-semibold">FROM 400 font-semibold">default.loans_portfolio_fact;

How does this architecture achieve compliance with BCBS 239 and SOX 404 audit requirements?#

  1. Deterministic Lineage: Every row in ClickHouse carries a cryptographic source_system identifier, ingested_at timestamp, and immutable snapshot date.
  2. Zero Manual Copy-Paste: Automated pipelines ingest data directly from source systems, eliminating the human formula errors that triggered the London Whale loss.
  3. Cryptographic Query Audit Log: The system.query_log records every user query, execution duration, and dataset scanned. SRE pipelines stream these logs to WORM (Write-Once-Read-Many) S3 buckets configured with Object Lock in Compliance Mode.

11. Architectural Consultation & Engineering Next Steps#

Replacing spreadsheet-based risk models with high-performance columnar architectures is a strategic necessity for institutions seeking regulatory compliance, operational resilience, and instantaneous portfolio intelligence.

At KNetwork, our Financial Technology & Systems Engineering Practice partners with institutional banks, credit funds, and neobanks to modernize mission-critical risk and data infrastructure:

  • ClickHouse Columnar Warehouse Deployment: Architecting production clusters, optimizing compression codecs, and designing sub-50ms aggregation views.
  • Credit Risk & Treasury Systems Engineering: Designing automated stress-testing pipelines, IFRS 9 / CECL staging engines, and BCBS 239 compliance frameworks.
  • Core Banking Data Integration: Building high-throughput Go and Python ingestion pipelines from legacy core banking engines (SAP, Temenos, FIS) into modern analytical storage.
  • Enterprise Risk Portals: Developing real-time risk visualization dashboards in Next.js 14, combining sub-50ms SSR with interactive parametric simulation controls.

Book an Architectural Discovery Session with Our Systems Architects or explore our Financial Services & FinTech Solutions and Custom Software Systems Architecture to eliminate spreadsheet risk bottlenecks forever.

Frequently Asked Strategic Questions

Technical and architectural governance answers for enterprise leadership.

D

Danisur Rahman

Practice Lead

Lead Systems Architect • KNetwork Advisory

Schedule Advisory Briefing

Advises enterprise technical leadership, CTOs, and heads of engineering on enterprise modernization, cloud migration governance, high-concurrency ledger design, and sovereign artificial intelligence compliance.