Functional Pipeline - C#
A production-grade data pipeline architecture combining five principles: functional core/imperative shell, contract-first validation, quality gates, data provenance with SHA-256 tamper detection, and semantic context propagation.
Data flow: HttpClient → JSON landing → FluentValidation → Bronze → LINQ transforms → Silver → LINQ aggregation → Gold → Parquet → HttpListener API
Two orthogonal dimensions of data trustworthiness:
- Structural integrity (vertical) — pure transforms, typed contracts, quality gates, immutable records
- Semantic integrity (horizontal) — column context, business context, temporal markers, lineage tracking
Quote
“The object-oriented version of spaghetti code is, of course, ‘lasagna code’. Too many layers.”
— Roberto Waltman
“State is never simple. State complects value and time.”
— Rich Hickey, Simple Made Easy, Strange Loop talk (2011)
Summary
Architecture & design principles (§ intro)
- Functional core / imperative shell: pure static transforms + I/O-only orchestration shell
- Two orthogonal trustworthiness axes: structural integrity (vertical) and semantic integrity (horizontal)
- Medallion architecture: Bronze → Silver → Gold with append-only Bronze and schema-on-write contracts
Records, contracts & validation (§ 2)
- Typed C#
recordDTOs at every layer boundary:RawOhlcv,CleanOhlcv,DailySummary,SymbolProfile- FluentValidation
AbstractValidator<T>enforces market invariants (High ≥ Low, return in [−50%, +50%])ColumnContextregistries attach unit, formula, null semantics, and source columns to every derived fieldBusinessContext/TemporalContext/StageContext/RunContextcarry semantic metadata cross-stageLineage & provenance (§ 3)
GenerateBatchId()stamps every row with a UUID;ComputeHash()produces a 16-char SHA-256 prefixStartStage()/EndStage()record input/output row counts and hash per stage intoStageLineageSQL Server schema (§ 4)
- Medallion DDL:
bronze_ohlcv,silver_ohlcv,gold_daily_summary,gold_symbol_profile- Quarantine table, lineage tables, and covering indexes for incremental ingestion guards
Dimension tables (§ 5)
- SCD Type 2
dim_symbolwith effective-date rows;dim_trading_calendarpopulated via exchange mapBronze layer (§ 6)
- Polly v8
ResiliencePipelineBuilderwrapsHttpClientYahoo Finance download with exponential back-off- Incremental ingestion:
WHERE NOT EXISTSguard prevents duplicate-key inserts;SqlBulkCopyfor batch writesSilver layer (§ 7)
- LINQ enrichment:
daily_return,intraday_range,sma_20(20-day rolling mean)- FluentValidation quality gate before SQL write; rejections routed to
bronze_quarantineGold layer (§ 8)
- LINQ aggregations:
DailySummary(cross-sectional per date) andSymbolProfile(full-history per symbol)max_drawdowncomputed as min(cumulative return − running max) per symbolParquet export (§ 9)
ParquetSharppre-materializes Gold to disk; serving layer reads file, not SQLLineage review (§ 10)
- Dapper queries: batch summary, stage drill-down, tamper-detection hash comparison, quarantine review
HttpListener serving layer (§ 11)
- Minimal HTTP API over pre-serialized JSON;
http://localhost:8080/prefixes for local use onlyPipeline visualization (§ 12)
- Plotly.NET charts: daily return distribution, volatility ranking, volume trend, quality metrics dashboard
Audit (§ 13)
- End-to-end dispute resolution: batch trace → hash verify → Silver drill-down → Bronze source confirm
Glossary
Medallion architecture
A three-tier storage pattern: Bronze (raw landing), Silver (cleaned/enriched), Gold (aggregated mart). Each tier has an append-only write policy so upstream data is never overwritten.
Failures in one tier are isolated — a bad Silver transform cannot corrupt Bronze; reprocessing replays from Bronze with the corrected logic.
Bronze as truncate-and-reload
WHERE NOT EXISTSguard on insert.Truncating Bronze on each run destroys the append-only audit trail and breaks replay. Bronze must be immutable; use an incremental
Functional core / imperative shell
Pure static methods perform all transforms (testable in isolation, no I/O); the outer orchestration layer owns all database, HTTP, and file system calls.
Business logic in the core can be unit-tested without SQL Server,
HttpClient, or file system dependencies.I/O inside transform methods
SqlConnection.Execute()inside a transform method makes it an integration test target only — it cannot be unit-tested without a live database.Embedding
record(C#)
An immutable reference type with compiler-generated value-based equality and a
withexpression for non-destructive field updates.Used as the DTO type across all layer boundaries;
init-only setters prevent post-construction mutation in transform chains.Mutable
classDTOs Aclasswith public setters allows any method in the transform chain to silently overwrite a field. Userecordso each stage produces a new instance viawith, leaving the source object untouched.
Schema-on-write (Design by Contract)
Data is validated against a typed contract at ingestion time, before any write to persistent storage. Bad rows are rejected at the boundary, not discovered downstream.
Contrast with schema-on-read (data lake pattern): bad data enters freely and surfaces months later in analytics results.
Origin
record+AbstractValidator<T>— it is version-controlled, testable, and enforced at runtime.The term derives from Bertrand Meyer’s Design by Contract (1986). In this pipeline the contract is a C#
FluentValidation
A .NET library that defines validation rules as composable
AbstractValidator<T>subclasses with full cross-field, conditional, and custom-message support.Enforces market invariants (High ≥ Low), business constraints (return in [−50%, +50%]), and referential requirements (
batch_idnot empty) at each layer boundary before writing to SQL Server.
DataAnnotations-only validation Attribute-basedDataAnnotationsdo not support cross-field rules (e.g., High ≥ Low), conditional logic, or custom error messages. Use FluentValidation for any non-trivial production gate.
Polly
A .NET resilience library providing retry, circuit breaker, timeout, and fallback policies via a composable builder API.
Wraps
HttpClientYahoo Finance downloads and SQL bulk-write operations to handle transient failures without manual retry loops.
Thread.Sleep()retry pattern Sleeping in acatchblock blocks the thread, cannot implement exponential back-off, and provides no circuit-breaking capability. UseResiliencePipelineBuilderwithAddRetryand an exponential delay.
ResiliencePipelineBuilder(Polly v8)
The Polly v8 API for composing resilience behaviors; replaces the
Policy.Handle<>()...Build()builder from Polly v7. Usesnew ResiliencePipelineBuilder<T>().AddRetry(...).Build().Configures retry with exponential back-off and
ShouldHandlepredicates that distinguish transient (HttpRequestException, SQL deadlock 1205) from permanent (JsonException,ValidationException) failures.Mixing Polly v7 and v8 APIs
The two versions have incompatible builder syntax. A project that references both compiles but throws at runtime. Standardize on Polly v8 for all new code and migrate v7 policies incrementally.
SHA-256 tamper detection
A cryptographic hash of a row’s sorted CSV payload stored alongside the data at write time; recomputing the hash on demand and comparing detects any unauthorized field modification.
ComputeHash()serializes aDataTableto sorted CSV bytes (column-name-ordered, row-sorted) and returns the first 16 hex characters of the SHA-256 digest for compact storage.Partial-field hashing
.Trim().ToLowerInvariant()) before hashing to prevent false mismatches from whitespace or encoding differences.Hashing only a subset of fields leaves unincluded fields modifiable without changing the hash. Hash the full row payload; normalize strings (
Lineage tracking
Recording metadata about every pipeline execution — source, row counts, run IDs, hash, timestamps — in a dedicated SQL audit table queryable at any future point.
Enables forensic queries that reconstruct exactly which execution produced any downstream number; answers “was the source data correct?”, “did the transform produce the right number?”, and “was the data modified post-ingestion?” with three SQL queries.
Log-file-only audit trail
Application logs are not queryable and rotate off disk. Store lineage in a SQL table so any disputed value can be traced back to its run ID, row counts, and hash without manual log archaeology.
StageLineage
An immutable
recordcapturing what a single pipeline stage produced:InputRows,OutputRows,RowsRejected,OutputHash(SHA-256),StartedAt,CompletedAt, and computedDurationMs.Written to the SQL lineage table at the end of each stage; referenced by
StageContext.UpstreamStagesso downstream stages carry the full execution chain.Distinction from
StageContextStageLineageis the after-the-fact audit record.StageContextis the live metadata object created at stage start and propagated forward viaForNextStage(). Both coexist — one for auditability, one for propagation.
BatchId
A
Guid.NewGuid()UUID generated once per pipeline run and stamped on every row in Bronze, Silver, and Gold.Acts as the foreign-key thread linking all rows across all tables to a single execution; the primary key for forensic queries like
WHERE batch_id = @runId.Granularity choice
BatchIdper full pipeline run (not per stage) means a single join onbatch_idspans all layers. If partial-stage reruns are needed, add astage_idcolumn alongsidebatch_id.One
ColumnContext
A
recordattaching semantic metadata to a single column:Description,Unit,Computation(formula),SourceColumns(upstream dependencies),ValidRange,NullSemantics,IsBusinessKey,IsDerived.Turns opaque numbers self-describing:
volatility: 0.0187becomes “daily σ of close-to-close returns, decimal_ratio, annualize with √252 → 29.7%“.Export as JSON Schema
ExportDataContracts()serializes each layer'sColumnContextregistry asx-column-contextinside a JSON Schema file. Any downstream consumer — API, AI agent, analyst — reads the contract without consulting the pipeline author.
BusinessContext
A
recordcapturing why a pipeline run was triggered:Trigger(scheduled / manual / backfill / reprocess / test),IsCorrection,BusinessDate,Reason,AffectedSymbols.Distinguishes routine runs from corrections; two batches covering the same date range are unambiguous — one has
trigger=scheduled, the othertrigger=reprocess, is_correction=true.Missing business context
BusinessContext, two executions over the same date range are indistinguishable in the lineage table. A correction that replaces wrong data looks identical to a routine run.Without
TemporalContext
A
recordseparatingAsOfDate(the business date the data represents, typically T−1) fromKnowledgeDate(when the pipeline ingested it, auto-set toDateTime.UtcNow).Critical for backfills: loading 2024 data in 2026 sets
as_of_date=2024-*,knowledge_date=2026-*, making the lag explicit in every lineage query.Bi-temporal model
AsOfDate= "what date is this data FOR".KnowledgeDate= "when did we learn about it". Without the split, a backfill looks identical to a normal run and late-arriving data cannot be detected.
StageContext
A mutable class created at stage start and carried forward via
ForNextStage(), accumulatingDataWarnings,UpstreamStages,ColumnCtx,BizContext, andTempContextacross every stage.By Gold, the context contains the full warning chain from every upstream stage — a single object that answers “what happened across the entire pipeline” for any given row.
Mutable by design
recordDTOs,StageContextis aclasswith mutable properties because it accumulates state across the pipeline.ForNextStage()creates a shallow copy to prevent stages from mutating each other's warning lists.Unlike the immutable
RunContext
A class aggregating the full execution envelope for one pipeline run:
BatchId,StartedAt,CompletedAt,Symbols,DateRange, allStageLineageentries,Status,BizContext,TempContext,DataWarnings,ContractVersion.Persisted as a JSON artifact per run alongside the data exports; the single artifact needed to reconstruct what any specific execution produced.
Audit use
RunContexttolineage/{batch_id}_run_context.jsonat pipeline completion. Any downstream dispute starts here: open the artifact, readStatus,DataWarnings, and the stage row counts before touching SQL.Serialize
Dapper
A micro-ORM that maps SQL query results to C# objects via reflection, without the overhead or abstraction of Entity Framework Core.
Used for all parameterized reads and lineage inserts; faster than EF Core for read-heavy pipelines because it executes raw SQL with named
@parambindings and avoids the N+1 pattern.String-concatenated Dapper queries
$"... WHERE symbol = '{symbol}'"is exploitable if any input comes from user-controlled or untrusted sources. Always usenew { symbol }parameter objects.Dapper executes whatever SQL string you provide. Building queries with
SCD Type 2
Slowly Changing Dimension Type 2: inserts a new row with
effective_from/effective_todates rather than overwriting the old record, preserving the full history of changes.Preserves historical accuracy for symbol metadata (name changes, sector reclassification) so index calculations using a historical date retrieve the value that was current on that date.
UPDATE on a dimension row
UPDATEdestroys the historical record. Any calculation that used the old value is now unreproducible. Use SCD2 insert-only pattern witheffective_to = '9999-12-31'for the active row.Overwriting a dimension row with
Parquet /
ParquetSharp
Parquet is a columnar storage format with predicate pushdown and efficient compression;
ParquetSharpis its C# binding for reading and writing Parquet files.Pre-materializes Gold aggregations to disk so the
HttpListenerAPI layer reads the file once at startup rather than issuing SQL queries per request.Default row group sizing
Writing Parquet without tuning row group size can produce files with poor predicate pushdown performance for large datasets. Set row group size explicitly based on the expected query filter cardinality.
SMA-20 (
sma_20)
20-trading-day simple moving average of the adjusted close price;
nullfor the first 19 rows of each symbol’s series (NullSemantics: "insufficient_data").The 19 expected nulls per symbol are documented in
ColumnContextand verified in the lineage review audit step; any null count above 19 indicates a data gap, not a warm-up artifact.Annualization reference
sma_20null warm-up period of 19 rows is exactlywindow_size − 1, consistent across all rolling-window indicators in the Silver layer.20 trading days ≈ one calendar month (252 trading days / 12). The
HttpListener
A built-in .NET class that hosts a minimal HTTP server by binding to a URI prefix, with no dependency on ASP.NET Core or any external framework.
Used in the serving layer to expose pre-materialized JSON endpoints over
http://localhost:8080/; the pipeline pre-serializes all Gold rows once at startup so each request is a string copy, not a SQL query.
HttpListenerin productionHttpListenerlacks TLS termination, authentication middleware, rate limiting, and URL routing beyond prefix matching. Never bind it tohttp://+:8080/on a public-facing host. Use ASP.NET Core Minimal API for production serving.
SqlBulkCopy
A .NET class that uses the TDS bulk-load protocol to insert large batches of rows in a single server round-trip, bypassing the per-row overhead of individual
INSERTstatements.Typically 10–50× faster than Dapper
ExecutewithINSERTfor batches above ~1,000 rows; used for Bronze landing writes where the full Yahoo Finance download arrives as one batch.When to switch from Dapper to
SqlBulkCopyFor batches up to ~500 rows, DapperExecutewith a list parameter is sufficient. Above 1,000 rows, switch toSqlBulkCopywithBatchSize = 5000andBulkCopyTimeout = 120to stay within SQL Server's lock escalation threshold.
Plotly.NET
A .NET binding for the Plotly JavaScript charting library, available via
Plotly.NET.CSharpfor C# fluent API usage in Jupyter notebooks withPlotly.NET.Interactive.Used in § 12 to render daily return distributions, volatility rankings, volume trends, and the quality metrics dashboard directly in the notebook output cell.
Notebook rendering
Plotly.NET.Interactiveregisters a .NET Interactive formatter that serializes charts to HTML and injects them into Jupyter output cells. No browser launch or file export is required for in-notebook display.
This note implements an end-to-end functional data pipeline in C#/.NET using FluentValidation, Dapper, LINQ transforms, and HttpListener with medallion architecture, lineage tracking, and SHA-256 tamper detection.
#r "nuget: Microsoft.Data.SqlClient"
#r "nuget: Dapper"
#r "nuget: FluentValidation"
#r "nuget: Polly"
#r "nuget: Plotly.NET, 5.1.0"
#r "nuget: Plotly.NET.Interactive, 5.0.0"
#r "nuget: Plotly.NET.CSharp, 0.13.0"
#r "nuget: Polars.NET"
#r "nuget: Polars.NET.Native.win-x64"
#r "nuget: ParquetSharp"
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Reflection;
using Microsoft.Data.SqlClient;
using Microsoft.DotNet.Interactive;
using Microsoft.DotNet.Interactive.CSharp;
using Microsoft.DotNet.Interactive.Formatting;
using Dapper;
using FluentValidation;
using Polly;
using Polly.Retry;
using Plotly.NET;
using Plotly.NET.CSharp;
using Chart = Plotly.NET.CSharp.Chart;
using Plotly.NET.LayoutObjects;
// ── Warning suppression ──
var csharpKernel = (CSharpKernel)Kernel.Root.FindKernelByName("csharp");
var optionsField = typeof(CSharpKernel).GetField("_scriptOptions",
BindingFlags.NonPublic | BindingFlags.Instance);
var scriptOptions = optionsField.GetValue(csharpKernel);
var withWarningLevel = scriptOptions.GetType().GetMethod("WithWarningLevel");
var newOptions = withWarningLevel.Invoke(scriptOptions, new object[] { 0 });
optionsField.SetValue(csharpKernel, newOptions);
// ── DataTable HTML formatter — transparent background ──
Formatter.Register<DataTable>((dt, writer) => {
writer.Write("<table style='border-collapse:collapse;background:transparent;color:inherit;'>");
writer.Write("<tr>");
foreach (DataColumn col in dt.Columns)
writer.Write($"<th style='text-align:left;padding:4px 12px;border-bottom:1px solid #555;'>{col.ColumnName}</th>");
writer.Write("</tr>");
foreach (DataRow row in dt.Rows) {
writer.Write("<tr>");
foreach (var val in row.ItemArray)
writer.Write($"<td style='text-align:left;padding:4px 12px;'>{val}</td>");
writer.Write("</tr>");
}
writer.Write("</table>");
}, "text/html");1. Configuration & Constants
Central configuration: paths, SQL connection, stock universe, date range. Every downstream cell references these constants — change them here, not in individual cells.
Project configuration
Defines all top-level constants for the pipeline run: filesystem paths, SQL Server connection string, stock universe, and date range.
C# — define pipeline paths, SQL connection, and stock universe
Central Configuration Cell
All downstream cells reference these constants — paths, SQL connection, stock universe, and date range. Change them here, not in individual cells.
// ── Paths ──
string DATA_DIR = @"C:\Users\aperi\DEV\LANG\data";
string EXPORT_DIR = Path.Combine(DATA_DIR, "pipeline");
string LINEAGE_DIR = Path.Combine(EXPORT_DIR, "lineage");
string LANDING_DIR = Path.Combine(DATA_DIR, "pipeline", "landing");
Directory.CreateDirectory(EXPORT_DIR);
Directory.CreateDirectory(LANDING_DIR);
Directory.CreateDirectory(LINEAGE_DIR);
// ── SQL Server (local Docker instance) ──
string SQL_CONN_STR = "Server=localhost,1434;Database=stoxx;User Id=sa;Password=EsgDev2026Pass1;Encrypt=yes;TrustServerCertificate=yes;";
SqlConnection sqlConn = new SqlConnection(SQL_CONN_STR);
sqlConn.Open();
// ── Stock universe — 5 EURO STOXX 50 components for demo ──
string[] SYMBOLS = { "SAP.DE", "SIE.DE", "ALV.DE", "DTE.DE", "BAS.DE" };
int LOOKBACK_DAYS = 365 * 2;
string START_DATE = DateTime.Today.AddDays(-LOOKBACK_DAYS).ToString("yyyy-MM-dd");
string END_DATE = DateTime.Today.ToString("yyyy-MM-dd");
// ── Exchange mapping: Yahoo Finance exchange code → calendar name ──
Dictionary<string, string> EXCHANGE_MAP = new() {
["GER"] = "XETR", ["FRA"] = "XFRA", ["PAR"] = "XPAR",
["AMS"] = "XAMS", ["BRU"] = "XBRU", ["MIL"] = "XMIL",
["MCE"] = "XMAD", ["NMS"] = "XNYS", ["NYQ"] = "XNYS",
["HKG"] = "XHKG", ["TKS"] = "XTKS",
};
Console.WriteLine(EXPORT_DIR); // export dir
Console.WriteLine(string.Join(", ", SYMBOLS)); // universe
Console.WriteLine($"{START_DATE} → {END_DATE}"); // date rangeC:\Users\aperi\DEV\LANG\data\pipeline
SAP.DE, SIE.DE, ALV.DE, DTE.DE, BAS.DE
2024-03-30 → 2026-03-30
2. Records + FluentValidation — Schema Validation at Every Boundary
Every stage boundary has a typed contract — a C# record that defines exactly what data can cross that boundary. Data that doesn’t conform is rejected BEFORE it crosses, with the rejection recorded in the quarantine table. This is Schema-on-Write (Design by Contract, Bertrand Meyer 1986): the contract is code — it runs at runtime, it’s version-controlled, it’s unit-testable. The opposite of data lake Schema-on-Read, where bad data enters freely and is discovered months later.
The models divide into three groups along two orthogonal dimensions — structural integrity (vertical: is the data correct?) and semantic integrity (horizontal: is the data meaningful?):
Structural models (boundary enforcement):
RawOhlcv— first line of defense: validates raw Yahoo Finance data at Bronze ingestionCleanOhlcv— validates enrichment transforms at Silver boundaryDailySummary/SymbolProfile— validates aggregation output at Gold boundary
Operational models (provenance tracking):
StageLineage— forensic record: row counts, SHA-256 hash, timing per stageRunContext— full pipeline execution envelope with business and temporal context
Semantic models (self-describing data):
ColumnContext— what each column means, its formula, unit, null semanticsBusinessContext— why this run was triggered (scheduled vs backfill vs correction)TemporalContext— as-of date vs knowledge date (bi-temporal)StageContext— propagated metadata flowing stage-to-stage with accumulated warnings
Without Typed Contracts
A renamed API field silently loads NULLs into bronze — every row, every day. A negative volume passes through to silver unchallenged. A NaN daily return poisons the gold aggregation. By the time a dashboard user notices, the damage is three layers deep and every downstream consumer has absorbed corrupt data. Contracts catch bad data at ingestion — one layer, one fix.
With Typed Contracts
C#
recordtypes with FluentValidation enforce schema and business rules at the bronze boundary. A renamed field throws aValidationExceptionon the first bad row — the pipeline stops, logs the rejection to quarantine, and the problem is fixed at the source before any corrupt data reaches silver or gold.
Medallion layer models
Typed record contracts for the three medallion layers (Bronze, Silver, Gold) plus the operational lineage model.
record — define Bronze validation model with record and properties
Bronze Contract: RawOhlcv
Validates raw Yahoo Finance data BEFORE persistence to SQL Server. Enforces positive prices, non-negative volume, symbol not empty. FluentValidation adds
High >= Low(market invariant — any violation = bad data). C# records are immutable by default.
public record RawOhlcv(
string Symbol,
DateTime Date,
double Open,
double High,
double Low,
double Close,
double AdjClose,
long Volume,
double Dividends = 0.0,
double StockSplits = 0.0);
public class RawOhlcvValidator : AbstractValidator<RawOhlcv>
{
public RawOhlcvValidator()
{
RuleFor(x => x.Symbol).NotEmpty();
RuleFor(x => x.Open).GreaterThan(0);
RuleFor(x => x.High).GreaterThan(0);
RuleFor(x => x.Low).GreaterThan(0);
RuleFor(x => x.Close).GreaterThan(0);
RuleFor(x => x.AdjClose).GreaterThan(0);
RuleFor(x => x.Volume).GreaterThanOrEqualTo(0);
RuleFor(x => x.Dividends).GreaterThanOrEqualTo(0);
RuleFor(x => x.StockSplits).GreaterThanOrEqualTo(0);
RuleFor(x => x.High).GreaterThanOrEqualTo(x => x.Low)
.WithMessage("high must be >= low (market invariant)");
}
}
var sample = new RawOhlcv("SAP.DE", new DateTime(2024, 1, 2), 144.5, 146.0, 143.8, 145.2, 145.2, 1_200_000);
var validator = new RawOhlcvValidator();
var result = validator.Validate(sample);SAP.DE 2024-01-02 close=145.2
record — define Silver validation model with record and properties
Silver Contract: CleanOhlcv
Extends Bronze with three computed fields:
daily_return,intraday_range,sma_20. Daily return constrained to [-50%, +50%] to catch extreme calculation errors.batch_idrequired — every Silver row must trace back to a pipeline run.
public record CleanOhlcv(
string Symbol,
DateTime Date,
double Open,
double High,
double Low,
double Close,
double AdjClose,
long Volume,
double Dividends,
double StockSplits,
double DailyReturn,
double IntradayRange,
double? Sma20,
string BatchId);
public class CleanOhlcvValidator : AbstractValidator<CleanOhlcv>
{
public CleanOhlcvValidator()
{
RuleFor(x => x.Symbol).NotEmpty();
RuleFor(x => x.Open).GreaterThan(0);
RuleFor(x => x.High).GreaterThan(0);
RuleFor(x => x.Low).GreaterThan(0);
RuleFor(x => x.Close).GreaterThan(0);
RuleFor(x => x.AdjClose).GreaterThan(0);
RuleFor(x => x.Volume).GreaterThanOrEqualTo(0);
RuleFor(x => x.IntradayRange).GreaterThanOrEqualTo(0);
RuleFor(x => x.High).GreaterThanOrEqualTo(x => x.Low)
.WithMessage("high must be >= low");
RuleFor(x => x.BatchId).NotEmpty();
}
}
record — define Gold validation models with record and properties
Gold Contracts: Two Mart Tables
DailySummary: one row per trading day with cross-sectional metrics.SymbolProfile: one row per symbol with full-history aggregate stats.max_drawdownconstrained to ⇐ 0 (always negative — peak-to-trough decline).
public record DailySummary(
DateTime Date,
int SymbolsTraded,
double AvgReturn,
double MaxReturn,
double MinReturn,
long TotalVolume,
double AvgIntradayPct,
string BatchId);
public record SymbolProfile(
string Symbol,
int TotalTradingDays,
double AvgDailyReturn,
double Volatility,
double MaxDrawdown,
double AvgVolume,
double TotalDividends,
DateTime FirstDate,
DateTime LastDate,
string BatchId);
8 fields
10 fields
record — define lineage tracking models with record and properties
Lineage Model: StageLineage
Records what a single pipeline stage produced:
input_rows/output_rows/rows_rejectedfor data flow accounting,output_hash(SHA-256) for tamper detection,DurationMscomputed from timestamps.
public record StageLineage(
string BatchId,
string Stage,
DateTime StartedAt,
DateTime CompletedAt,
int InputRows,
int OutputRows,
int RowsRejected,
string OutputHash)
{
public double DurationMs => (CompletedAt - StartedAt).TotalMilliseconds;
}Context Architecture — Semantic Metadata Layer
This is where the two dimensions of the architecture intersect. The structural models above ensure the pipeline produces correct data. The semantic models below ensure the data is meaningful to any consumer — another pipeline, a dashboard, an LLM agent, an auditor — without reading the pipeline source code.
- ColumnContext — documents what each column means, how it was derived, what NULL signifies. Without it,
volatility: 0.0187is an opaque number. With it: unit=decimal_ratio, formula=std(daily_return), annualize with sqrt(252) - BusinessContext — records WHY this run happened. Without it, two batches covering the same date range are indistinguishable. With it, one is
trigger=scheduledand the other istrigger=reprocess, is_correction=true - TemporalContext — separates “what date is this data FOR” (as_of_date) from “when did we learn about it” (knowledge_date). Without it, a backfill loading 2024 data in 2026 looks like a normal 2026 run
- StageContext — carries all of the above THROUGH the pipeline. Each stage inherits upstream warnings and adds its own. By gold, the context contains the full warning chain from every stage
Without Semantic Context
An AI agent queries gold_symbol_profile and sees
volatility: 0.0187. It doesn’t know if that’s a percentage or a decimal, daily or annual, what formula produced it, or what NULL would mean. It guesses — or hallucinates an interpretation. The data contract eliminates this: unit=decimal_ratio, formula=std(daily_return), annualize with sqrt(252). The number becomes self-describing.
With Semantic Context
ColumnContextrecords attach unit, formula, null semantics, and source columns to every field in the gold contract. Any consumer — API, AI agent, analyst — reads the contract and knows exactly what each number means without consulting the pipeline author.
record — define column semantic metadata model with record
Semantic Metadata: ColumnContext
Describes WHAT a column means, not just its type.
computation: formula used to derive it.source_columns: upstream dependencies.null_semantics: what NULL means (“insufficient_data” vs “source_missing”).is_derived: True = computed by pipeline, False = raw from source.
public record ColumnContext(
string Name,
string Description,
string Unit,
string Computation = null,
string[] SourceColumns = null,
(double Min, double Max)? ValidRange = null,
string NullSemantics = "not_applicable",
bool IsBusinessKey = false,
bool IsDerived = false)
{
public string[] SourceColumns { get; init; } = SourceColumns ?? Array.Empty<string>();
}C# — define column registries for each medallion layer
Column Registries per Layer
Each column has a
ColumnContextentry documenting what it is, how it was computed, what NULL means, and its valid range. These registries feed into data contracts (exported as JSON Schema) and attach toStageContextfor cross-stage propagation.
List<ColumnContext> BRONZE_COLUMNS = new() {
new("symbol", "Yahoo Finance ticker symbol", "identifier", IsBusinessKey: true),
new("date", "Trading date (exchange local)", "date", IsBusinessKey: true),
new("open", "Opening price", "EUR", ValidRange: (0.001, 100000)),
new("high", "Highest price", "EUR", ValidRange: (0.001, 100000)),
new("low", "Lowest price", "EUR", ValidRange: (0.001, 100000)),
new("close", "Closing price", "EUR", ValidRange: (0.001, 100000)),
new("adj_close", "Adjusted close", "EUR", ValidRange: (0.001, 100000)),
new("volume", "Shares traded", "count", ValidRange: (0, 1e12)),
new("dividends", "Dividend paid", "EUR", ValidRange: (0, 1000)),
new("stock_splits", "Split ratio", "ratio", ValidRange: (0, 100)),
};
List<ColumnContext> SILVER_COLUMNS = new(BRONZE_COLUMNS) {
new("daily_return", "Close-to-close return", "decimal_ratio",
Computation: "pct_change(close).over(symbol)", SourceColumns: new[]{"bronze.close"},
ValidRange: (-0.5, 0.5), NullSemantics: "first_row_in_series", IsDerived: true),
new("intraday_range", "(high-low)/close", "decimal_ratio",
Computation: "(high - low) / close", SourceColumns: new[]{"bronze.high","bronze.low","bronze.close"},
ValidRange: (0, 0.5), IsDerived: true),
new("sma_20", "20-day moving average of close", "EUR",
Computation: "close.rolling_mean(20).over(symbol)", SourceColumns: new[]{"bronze.close"},
ValidRange: (0.001, 100000), NullSemantics: "insufficient_data", IsDerived: true),
};
List<ColumnContext> GOLD_DAILY_COLUMNS = new() {
new("date", "Trading date", "date", IsBusinessKey: true),
new("symbols_traded", "Distinct symbols", "count",
Computation: "count(distinct symbol) per date", SourceColumns: new[]{"silver.symbol"}, IsDerived: true),
new("avg_return", "Mean daily return", "decimal_ratio",
Computation: "mean(daily_return) per date", SourceColumns: new[]{"silver.daily_return"}, IsDerived: true),
new("max_return", "Best return", "decimal_ratio", IsDerived: true),
new("min_return", "Worst return", "decimal_ratio", IsDerived: true),
new("total_volume", "Sum of volume", "count", IsDerived: true),
new("avg_intraday_pct", "Mean intraday range", "decimal_ratio", IsDerived: true),
};
List<ColumnContext> GOLD_PROFILE_COLUMNS = new() {
new("symbol", "Yahoo Finance ticker symbol", "identifier", IsBusinessKey: true, NullSemantics: "not_applicable"),
new("total_trading_days", "Number of trading days with data", "count",
Computation: "count(*) per symbol", SourceColumns: new[]{"silver.date"}, ValidRange: (1, 5000), IsDerived: true),
new("avg_daily_return", "Mean daily close-to-close return over full history", "decimal_ratio",
Computation: "mean(daily_return) per symbol", SourceColumns: new[]{"silver.daily_return"}, ValidRange: (-0.1, 0.1), IsDerived: true),
new("volatility", "Standard deviation of daily returns \u2014 annualize by multiplying by sqrt(252)", "decimal_ratio",
Computation: "std(daily_return) per symbol", SourceColumns: new[]{"silver.daily_return"}, ValidRange: (0, 1), IsDerived: true),
new("max_drawdown", "Largest peak-to-trough decline in cumulative return (always negative or zero)", "decimal_ratio",
Computation: "min(cumulative_return - running_max(cumulative_return)) per symbol",
SourceColumns: new[]{"silver.daily_return"}, ValidRange: (-1, 0), IsDerived: true),
new("avg_volume", "Mean daily trading volume over full history", "count",
Computation: "mean(volume) per symbol", SourceColumns: new[]{"silver.volume"}, ValidRange: (0, 1e12), IsDerived: true),
new("total_dividends", "Sum of all dividends paid over full history", "EUR",
Computation: "sum(dividends) per symbol", SourceColumns: new[]{"silver.dividends"}, ValidRange: (0, 10000), IsDerived: true),
};
Console.WriteLine($"Column registries: Bronze={BRONZE_COLUMNS.Count}, Silver={SILVER_COLUMNS.Count}, " +
$"Gold Daily={GOLD_DAILY_COLUMNS.Count}, Gold Profile={GOLD_PROFILE_COLUMNS.Count}");Bronze=10, Silver=13, Gold Daily=7, Gold Profile=7
record — define business context model with record
BusinessContext: Run Trigger Reason
Captures WHY this pipeline execution happened.
trigger: scheduled, manual, backfill, reprocess, or test.is_correction: true if overwriting previously published data. Enables downstream consumers to distinguish routine runs from corrections.
public record BusinessContext
{
public string Trigger { get; init; }
public string Reason { get; init; }
public DateTime BusinessDate { get; init; } = DateTime.Today;
public bool IsCorrection { get; init; }
public string[] AffectedSymbols { get; init; }
public static readonly HashSet<string> ValidTriggers = new() {
"scheduled", "manual", "backfill", "reprocess", "test"
};
}record — define temporal context model with record
TemporalContext: Bi-Temporal Markers
as_of_date: the business date the data represents (usually T-1).knowledge_date: when the pipeline ingested it (auto-set to now). Separates “what date is this data FOR” from “when did we learn about it” — critical for backfills whereknowledge_date >> as_of_date.
public record TemporalContext
{
public DateTime AsOfDate { get; init; }
public DateTime ReportingPeriodStart { get; init; }
public DateTime ReportingPeriodEnd { get; init; }
public DateTime KnowledgeDate { get; init; } = DateTime.UtcNow;
public string Timezone { get; init; } = "UTC";
public bool IsBackfill { get; init; }
}record — define stage context model for cross-stage propagation with record
StageContext: Cross-Stage Propagation
Unlike
StageLineage(recorded after the fact),StageContextis created at stage start and carried forward viaForNextStage(). Each stage inherits upstream warnings and adds its own. By gold, the context carries the full warning chain from all stages.
public class StageContext
{
public string BatchId { get; set; }
public string Stage { get; set; }
public List<StageLineage> UpstreamStages { get; set; } = new();
public List<string> DataWarnings { get; set; } = new();
public string SchemaVersion { get; set; } = "1.0";
public List<ColumnContext> ColumnCtx { get; set; } = new();
public BusinessContext BizContext { get; set; }
public TemporalContext TempContext { get; set; }
public void AddWarning(string warning)
{
DataWarnings.Add(warning);
Console.WriteLine($" WARNING {Stage}: {warning}");
}
public StageContext ForNextStage(string nextStage, StageLineage lineage,
List<ColumnContext> columns)
{
return new StageContext {
BatchId = BatchId, Stage = nextStage,
UpstreamStages = new List<StageLineage>(UpstreamStages) { lineage },
DataWarnings = new List<string>(DataWarnings),
SchemaVersion = SchemaVersion, ColumnCtx = columns,
BizContext = BizContext, TempContext = TempContext,
};
}
}record — define pipeline run context model with record
RunContext: Execution Envelope
Aggregates everything: stages, business context, temporal context, data warnings, and contract version into a single JSON artifact per pipeline execution.
// accumulated warnings, contract version. Persisted as JSON per run.
public class RunContext
{
public string BatchId { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string[] Symbols { get; set; }
public string[] DateRange { get; set; }
public string PolarsVersion { get; set; } = "Polars.NET";
public List<StageLineage> Stages { get; set; } = new();
public string Status { get; set; } = "running";
public BusinessContext BizContext { get; set; }
public TemporalContext TempContext { get; set; }
public List<string> DataWarnings { get; set; } = new();
public string ContractVersion { get; set; } = "1.0";
}C# — define data contract export function with JsonSerializer
Data Contract Export to JSON Schema
Generates machine-readable contracts for each pipeline boundary. Each contract includes structural schema (types, constraints) PLUS
x-column-contextwith descriptions, formulas, units, and null semantics.
List<string> ExportDataContracts(string exportDir)
{
var contractsDir = Path.Combine(exportDir, "contracts");
Directory.CreateDirectory(contractsDir);
var registry = new Dictionary<string, List<ColumnContext>> {
["bronze_ohlcv"] = BRONZE_COLUMNS,
["silver_ohlcv"] = SILVER_COLUMNS,
["gold_daily_summary"] = GOLD_DAILY_COLUMNS,
["gold_symbol_profile"] = GOLD_PROFILE_COLUMNS,
};
var paths = new List<string>();
foreach (var (name, columns) in registry)
{
var properties = new Dictionary<string, object>();
foreach (var col in columns)
{
properties[col.Name] = new Dictionary<string, object> {
["description"] = col.Description,
["x-unit"] = col.Unit,
};
}
var schema = new Dictionary<string, object> {
["type"] = "object",
["properties"] = properties,
["x-column-context"] = columns.Select(c => new Dictionary<string, object> {
["name"] = c.Name, ["description"] = c.Description,
["unit"] = c.Unit, ["computation"] = c.Computation ?? "",
["source_columns"] = c.SourceColumns,
["valid_range"] = c.ValidRange.HasValue
? new[] { c.ValidRange.Value.Min, c.ValidRange.Value.Max } : null,
["null_semantics"] = c.NullSemantics,
["is_business_key"] = c.IsBusinessKey,
["is_derived"] = c.IsDerived,
}).ToArray(),
["x-contract-version"] = "1.0",
["x-generated-at"] = DateTime.UtcNow.ToString("o"),
};
var path = Path.Combine(contractsDir, $"{name}_contract.json");
File.WriteAllText(path, JsonSerializer.Serialize(schema, new JsonSerializerOptions { WriteIndented = true }));
paths.Add(path);
Console.WriteLine($" Contract exported: {Path.GetFileName(path)}");
}
return paths;
}
3. Lineage & Context Infrastructure
These functions implement the ability to trace any data point from Gold back to its raw source with cryptographic proof. batch_id is the thread — every row in every table carries the UUID of the pipeline run that created it. compute_hash() produces a deterministic SHA-256: same data → same hash. If someone modifies a Silver row after the pipeline ran, the recomputed hash won’t match the recorded one. RunContext captures the full execution envelope — which symbols, what date range, which library versions, how many rejections.
Without Lineage Tracking
A stakeholder disputes a -16% drop in gold. Without lineage, you spend a day manually checking: was the source data correct? Did the transform produce the right number? Was the data modified after ingestion? With lineage, three queries answer all three questions — batch_id traces the row to its run, the hash proves no tampering, the RunContext shows zero rejections and the exact date range processed.
With Lineage Tracking
batch_idlinks every row to its pipeline run. SHA-256 hashes prove rows were not modified after ingestion.RunContextrecords symbol count, date range, and rejection count at commit time. Any dispute is resolved with three SQL queries — no manual archaeology.
Lineage helpers
Core functions for generating batch IDs, computing SHA-256 tamper-detection hashes, and recording start/end stage metrics.
Guid — generate unique batch ID with Guid.NewGuid()
Batch ID: Unique Run Identifier
Every row in bronze/silver/gold carries this UUID. Trace any disputed value back to its pipeline run in one query.
string GenerateBatchId()
{
return Guid.NewGuid().ToString();
}
// Demo: generate a batch_id
var demoBatch = GenerateBatchId();4af95c21-c810-416d-81ec-75d175e0d6d3
SHA256 — compute deterministic DataTable hash with SHA256.HashData()
SHA-256 Hash: Tamper Detection
Same data → same hash, every time. If someone modifies a row after the pipeline ran, the recomputed hash won’t match. Serializes DataTable to sorted CSV bytes before hashing.
string ComputeHash(DataTable dt)
{
var sb = new StringBuilder();
var colNames = dt.Columns.Cast<DataColumn>().Select(c => c.ColumnName).OrderBy(c => c).ToList();
sb.AppendLine(string.Join(",", colNames));
var rows = dt.AsEnumerable().OrderBy(r => string.Join(",", colNames.Select(c => r[c]?.ToString() ?? "")));
foreach (var row in rows)
sb.AppendLine(string.Join(",", colNames.Select(c => row[c]?.ToString() ?? "")));
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
return Convert.ToHexString(hash).ToLower().Substring(0, 16);
}
// Demo with a small table
var demoDt = new DataTable();
demoDt.Columns.Add("a", typeof(int));
demoDt.Columns.Add("b", typeof(int));
demoDt.Rows.Add(1, 4); demoDt.Rows.Add(2, 5); demoDt.Rows.Add(3, 6);68135205be4bfc0c
C# — define stage start and end tracker with DateTime.UtcNow
Stage Tracking: Start/End Pattern
StartStage(): captures timestamp and input row count at entry.EndStage(): fills output metrics, computes SHA-256 hash, returnsStageLineage.StageContext(if provided) flows alongside for semantic metadata propagation.
Dictionary<string, object> StartStage(string batchId, string stage, int inputRows,
StageContext stageContext = null)
{
return new Dictionary<string, object> {
["batch_id"] = batchId,
["stage"] = stage,
["started_at"] = DateTime.UtcNow,
["input_rows"] = inputRows,
["stage_context"] = stageContext,
};
}
StageLineage EndStage(Dictionary<string, object> ctx, DataTable outputDt,
int rowsRejected = 0)
{
return new StageLineage(
BatchId: (string)ctx["batch_id"],
Stage: (string)ctx["stage"],
StartedAt: (DateTime)ctx["started_at"],
CompletedAt: DateTime.UtcNow,
InputRows: (int)ctx["input_rows"],
OutputRows: outputDt.Rows.Count,
RowsRejected: rowsRejected,
OutputHash: ComputeHash(outputDt)
);
}
C# — save run context to JSON with JsonSerializer.Serialize()
Save RunContext to JSON
One JSON file per run, named by batch_id prefix. Full audit trail on disk.
string SaveRunContext(RunContext ctx)
{
var path = Path.Combine(LINEAGE_DIR, $"run_{ctx.BatchId.Substring(0, 8)}.json");
File.WriteAllText(path, JsonSerializer.Serialize(ctx, new JsonSerializerOptions { WriteIndented = true }));
return path;
}
4. SQL Server Schema — Medallion Tables + Lineage
Nine tables implementing the full architecture — not just data storage but the complete operational infrastructure. Three groups: medallion tables (bronze_ohlcv, silver_ohlcv, gold_daily_summary, gold_symbol_profile) store data at three stages of refinement. Dimension tables (dim_symbol, dim_calendar) provide business context that enables context-driven decisions. Operational tables (lineage_stages, quarantine, context_log) store the metadata that makes the pipeline auditable, recoverable, and self-describing.
Without Operational Tables
Without
lineage_stages: no record of which batch produced which rows. Withoutquarantine: rejected rows disappear — you never know they existed, never know what was wrong with them, can never replay them. Withoutcontext_log: the pipeline’s knowledge about holidays, expected nulls, and business triggers is lost the moment the process exits.
With Operational Tables
lineage_stagesmakes every run auditable and replayable.quarantinepreserves rejected rows with full error context — any bad batch can be fixed and replayed without re-fetching from the API.context_logpersists pipeline decisions so post-run queries can explain every warning.
| Table | Purpose | Key |
|---|---|---|
bronze_ohlcv | Raw Yahoo Finance data, untransformed | (symbol, date) |
silver_ohlcv | Enriched with daily_return, sma_20 | (symbol, date) |
gold_daily_summary | Cross-sectional daily metrics | (date) |
gold_symbol_profile | Per-symbol aggregate stats | (symbol) |
dim_symbol | SCD Type 2 company metadata | (symbol, valid_from) |
dim_calendar | Per-exchange trading day flags | (date, exchange_code) |
lineage_stages | Stage-level execution metadata | (batch_id, stage) |
quarantine | Dead letter queue for rejected rows | (batch_id, stage) |
context_log | Semantic context per stage per run | (batch_id, stage) |
Table definitions
DDL for the nine medallion, dimension, and operational tables.
SQL Server — create Bronze OHLCV table with sqlConn.Execute()
Bronze Table: Raw Source Data
Stores raw Yahoo Finance output exactly as received.
batch_idlinks every row to the pipeline run that ingested it.UNIQUEon(symbol, date)enables MERGE upsert for incremental loads.
sqlConn.Execute(@"
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'bronze_ohlcv')
CREATE TABLE bronze_ohlcv (
id INT IDENTITY(1,1) PRIMARY KEY,
symbol VARCHAR(20) NOT NULL,
date DATE NOT NULL,
[open] FLOAT NOT NULL,
high FLOAT NOT NULL,
low FLOAT NOT NULL,
[close] FLOAT NOT NULL,
adj_close FLOAT NOT NULL,
volume BIGINT NOT NULL,
dividends FLOAT NOT NULL DEFAULT 0,
stock_splits FLOAT NOT NULL DEFAULT 0,
batch_id VARCHAR(36) NOT NULL,
ingested_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
CONSTRAINT UQ_bronze_symbol_date UNIQUE (symbol, date)
)
");SQL Server — create Silver OHLCV table with sqlConn.Execute()
Silver Table DDL
Adds computed columns: daily_return, intraday_range, sma_20. UNIQUE on (symbol, date) enables MERGE upsert.
sqlConn.Execute(@"
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'silver_ohlcv')
CREATE TABLE silver_ohlcv (
id INT IDENTITY(1,1) PRIMARY KEY NONCLUSTERED,
symbol VARCHAR(20) NOT NULL,
date DATE NOT NULL,
[open] FLOAT NOT NULL,
high FLOAT NOT NULL,
low FLOAT NOT NULL,
[close] FLOAT NOT NULL,
adj_close FLOAT NOT NULL,
volume BIGINT NOT NULL,
dividends FLOAT NOT NULL DEFAULT 0,
stock_splits FLOAT NOT NULL DEFAULT 0,
daily_return FLOAT NOT NULL,
intraday_range FLOAT NOT NULL,
sma_20 FLOAT NULL,
batch_id VARCHAR(36) NOT NULL,
processed_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
INDEX IX_silver_symbol_date CLUSTERED (symbol, date),
CONSTRAINT UQ_silver_symbol_date UNIQUE (symbol, date)
)
");SQL Server — create Gold daily summary table with sqlConn.Execute()
Gold Daily Summary DDL
One row per trading day with cross-sectional metrics. Clustered on date for efficient range scans.
sqlConn.Execute(@"
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'gold_daily_summary')
CREATE TABLE gold_daily_summary (
id INT IDENTITY(1,1) PRIMARY KEY NONCLUSTERED,
date DATE NOT NULL,
symbols_traded INT NOT NULL,
avg_return FLOAT NOT NULL,
max_return FLOAT NOT NULL,
min_return FLOAT NOT NULL,
total_volume BIGINT NOT NULL,
avg_intraday_pct FLOAT NOT NULL,
batch_id VARCHAR(36) NOT NULL,
INDEX IX_gold_daily_date CLUSTERED (date)
)
");SQL Server — create Gold symbol profile table with sqlConn.Execute()
Gold Symbol Profile DDL
One row per symbol with aggregate statistics. Clustered on symbol for efficient lookups.
sqlConn.Execute(@"
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'gold_symbol_profile')
CREATE TABLE gold_symbol_profile (
id INT IDENTITY(1,1) PRIMARY KEY NONCLUSTERED,
symbol VARCHAR(20) NOT NULL,
total_trading_days INT NOT NULL,
avg_daily_return FLOAT NOT NULL,
volatility FLOAT NOT NULL,
max_drawdown FLOAT NOT NULL,
avg_volume FLOAT NOT NULL,
total_dividends FLOAT NOT NULL DEFAULT 0,
first_date DATE NOT NULL,
last_date DATE NOT NULL,
batch_id VARCHAR(36) NOT NULL,
INDEX IX_gold_profile_symbol CLUSTERED (symbol)
)
");SQL Server — create SCD Type 2 symbol dimension with sqlConn.Execute()
SCD Type 2 Dimension DDL
Tracks historical changes in symbol metadata. valid_from/valid_to/is_current enable point-in-time queries.
sqlConn.Execute(@"
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'dim_symbol')
CREATE TABLE dim_symbol (
id INT IDENTITY(1,1) PRIMARY KEY,
symbol VARCHAR(20) NOT NULL,
company_name NVARCHAR(200) NULL,
short_name NVARCHAR(100) NULL,
sector NVARCHAR(100) NULL,
sector_key VARCHAR(100) NULL,
industry NVARCHAR(200) NULL,
industry_key VARCHAR(200) NULL,
country NVARCHAR(100) NULL,
city NVARCHAR(100) NULL,
exchange VARCHAR(20) NULL,
full_exchange_name NVARCHAR(100) NULL,
currency VARCHAR(10) NULL,
market_cap BIGINT NULL,
website VARCHAR(500) NULL,
-- SCD Type 2 columns
valid_from DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
valid_to DATETIME2 NULL,
is_current BIT NOT NULL DEFAULT 1
)
");SQL Server — create per-exchange trading calendar with sqlConn.Execute()
Trading Calendar Dimension
Per-exchange trading calendar with holiday flags. Composite PK on . Aligned with the stoxx.bronze.trading_calendar schema.
sqlConn.Execute(@"
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'dim_calendar')
CREATE TABLE dim_calendar (
date DATE NOT NULL,
exchange_code VARCHAR(10) NOT NULL,
year SMALLINT NOT NULL,
quarter TINYINT NOT NULL,
month TINYINT NOT NULL,
week_of_year TINYINT NOT NULL,
day_of_week TINYINT NOT NULL,
is_trading_day BIT NOT NULL DEFAULT 0,
is_month_end BIT NOT NULL DEFAULT 0,
is_quarter_end BIT NOT NULL DEFAULT 0,
CONSTRAINT PK_dim_calendar PRIMARY KEY (date, exchange_code)
)
");SQL Server — create lineage tracking table with Execute()
Lineage Table DDL
Persists StageLineage records to SQL Server. Enables querying pipeline history: which batch produced what.
sqlConn.Execute(@"
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'lineage_stages')
CREATE TABLE lineage_stages (
id INT IDENTITY(1,1) PRIMARY KEY,
batch_id VARCHAR(36) NOT NULL,
stage VARCHAR(20) NOT NULL,
started_at DATETIME2 NOT NULL,
completed_at DATETIME2 NOT NULL,
input_rows INT NOT NULL,
output_rows INT NOT NULL,
rows_rejected INT NOT NULL DEFAULT 0,
output_hash VARCHAR(16) NOT NULL
)
");SQL Server — create quarantine table for rejected rows with Execute()
Quarantine: Dead Letter Queue
Stores every row that failed validation. Preserves the raw data + rejection reason for investigation and replay.
sqlConn.Execute(@"
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'quarantine')
CREATE TABLE quarantine (
id INT IDENTITY(1,1) PRIMARY KEY,
batch_id VARCHAR(36) NOT NULL,
stage VARCHAR(20) NOT NULL,
symbol VARCHAR(20) NULL,
date DATE NULL,
raw_data NVARCHAR(MAX) NOT NULL,
error_message NVARCHAR(MAX) NOT NULL,
quarantined_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
)
");SQL Server — create context log table with Execute()
// Persists StageContext records — business context, temporal context, warnings
sqlConn.Execute(@"
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'context_log')
CREATE TABLE context_log (
id INT IDENTITY(1,1) PRIMARY KEY,
batch_id VARCHAR(36) NOT NULL,
stage VARCHAR(20) NOT NULL,
business_date DATE NULL,
trigger_type VARCHAR(20) NULL,
is_correction BIT NOT NULL DEFAULT 0,
schema_version VARCHAR(10) NOT NULL DEFAULT '1.0',
data_warnings NVARCHAR(MAX) NULL,
column_context NVARCHAR(MAX) NULL,
temporal_json NVARCHAR(MAX) NULL,
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE()
)
");Persistence helpers
Helper functions for writing stage context, lineage records, upsert data, and quarantine rows to SQL Server.
SQL Server — define context persistence helper with Execute()
Writes a StageContext record to the context_log table.
void PersistContext(StageContext stageCtx)
{
if (stageCtx == null) return;
sqlConn.Execute(
@"INSERT INTO context_log
(batch_id, stage, business_date, trigger_type, is_correction,
schema_version, data_warnings, column_context, temporal_json)
VALUES (@BatchId, @Stage, @BizDate, @Trigger, @IsCorrection,
@SchemaVer, @Warnings, @ColCtx, @TempJson)",
new {
stageCtx.BatchId, stageCtx.Stage,
BizDate = stageCtx.BizContext?.BusinessDate,
Trigger = stageCtx.BizContext?.Trigger,
IsCorrection = stageCtx.BizContext?.IsCorrection ?? false,
SchemaVer = stageCtx.SchemaVersion,
Warnings = stageCtx.DataWarnings.Any()
? JsonSerializer.Serialize(stageCtx.DataWarnings) : null,
ColCtx = stageCtx.ColumnCtx.Any()
? JsonSerializer.Serialize(stageCtx.ColumnCtx.Select(c => new {
c.Name, c.Description, c.Unit, c.Computation,
source_columns = c.SourceColumns, c.NullSemantics,
is_business_key = c.IsBusinessKey, is_derived = c.IsDerived,
valid_range = c.ValidRange.HasValue
? new[]{ c.ValidRange.Value.Min, c.ValidRange.Value.Max } : null
})) : null,
TempJson = stageCtx.TempContext != null
? JsonSerializer.Serialize(stageCtx.TempContext) : null,
});
}
SQL Server — define lineage persistence helper with Execute()
Idempotent Lineage Persistence
Inserts a StageLineage into lineage_stages. Deletes any existing record for the same batch+stage first.
void PersistLineage(StageLineage lineage)
{
sqlConn.Execute(
"DELETE FROM lineage_stages WHERE batch_id = @BatchId AND stage = @Stage",
new { lineage.BatchId, lineage.Stage });
sqlConn.Execute(
@"INSERT INTO lineage_stages
(batch_id, stage, started_at, completed_at, input_rows, output_rows, rows_rejected, output_hash)
VALUES (@BatchId, @Stage, @StartedAt, @CompletedAt, @InputRows, @OutputRows, @RowsRejected, @OutputHash)",
new {
lineage.BatchId, lineage.Stage, lineage.StartedAt, lineage.CompletedAt,
lineage.InputRows, lineage.OutputRows, lineage.RowsRejected, lineage.OutputHash
});
}
Dapper — define DataFrame write helper with Execute()
DataFrame Write Helper
Writes a DataTable to SQL Server using Dapper row-by-row. wipes the table before insert (used by Gold tables only).
DataTable QueryToTable(string sql, object param = null)
{
var reader = sqlConn.ExecuteReader(sql, param);
var dt = new DataTable();
dt.Load(reader);
return dt;
}
void WriteToCsv(DataTable dt, string table, bool truncate = true)
{
if (truncate)
sqlConn.Execute($"TRUNCATE TABLE {table}");
foreach (DataRow row in dt.Rows)
{
var cols = string.Join(", ", dt.Columns.Cast<DataColumn>()
.Select(c => c.ColumnName == "open" || c.ColumnName == "close"
? $"[{c.ColumnName}]" : c.ColumnName));
var parms = string.Join(", ", dt.Columns.Cast<DataColumn>()
.Select(c => $"@{c.ColumnName}"));
var parameters = new DynamicParameters();
foreach (DataColumn col in dt.Columns)
parameters.Add(col.ColumnName, row[col]);
sqlConn.Execute($"INSERT INTO {table} ({cols}) VALUES ({parms})", parameters);
}
}
SQL Server — define Bronze MERGE upsert with MERGE INTO
Bronze MERGE Upsert
Idempotent: MERGE on — safe to re-run. Existing rows get updated, new rows get inserted.
int MergeBronze(DataTable dt, string batchId)
{
int rowsAffected = 0;
foreach (DataRow row in dt.Rows)
{
rowsAffected += sqlConn.Execute(@"
MERGE bronze_ohlcv AS tgt
USING (SELECT @symbol AS symbol, @date AS date) AS src
ON tgt.symbol = src.symbol AND tgt.date = src.date
WHEN MATCHED THEN UPDATE SET
[open] = @open, high = @high, low = @low, [close] = @close,
adj_close = @adj_close, volume = @volume, dividends = @dividends,
stock_splits = @stock_splits,
batch_id = @batch_id, ingested_at = GETUTCDATE()
WHEN NOT MATCHED THEN INSERT
(symbol, date, [open], high, low, [close], adj_close,
volume, dividends, stock_splits, batch_id)
VALUES (@symbol, @date, @open, @high, @low, @close, @adj_close,
@volume, @dividends, @stock_splits, @batch_id);",
new {
symbol = row["symbol"].ToString(), date = (DateTime)row["date"],
open = Convert.ToDouble(row["open"]), high = Convert.ToDouble(row["high"]),
low = Convert.ToDouble(row["low"]), close = Convert.ToDouble(row["close"]),
adj_close = Convert.ToDouble(row["adj_close"]),
volume = Convert.ToInt64(row["volume"]),
dividends = Convert.ToDouble(row["dividends"]),
stock_splits = Convert.ToDouble(row["stock_splits"]),
batch_id = batchId,
});
}
return rowsAffected;
}
SQL Server — define Silver MERGE upsert with MERGE INTO
Silver MERGE Upsert
Same idempotent pattern as Bronze, but includes enrichment columns: , , .
int MergeSilver(DataTable dt, string batchId)
{
int rowsAffected = 0;
foreach (DataRow row in dt.Rows)
{
rowsAffected += sqlConn.Execute(@"
MERGE silver_ohlcv AS tgt
USING (SELECT @symbol AS symbol, @date AS date) AS src
ON tgt.symbol = src.symbol AND tgt.date = src.date
WHEN MATCHED THEN UPDATE SET
[open] = @open, high = @high, low = @low, [close] = @close,
adj_close = @adj_close, volume = @volume, dividends = @dividends,
stock_splits = @stock_splits,
daily_return = @daily_return, intraday_range = @intraday_range, sma_20 = @sma_20,
batch_id = @batch_id, processed_at = GETUTCDATE()
WHEN NOT MATCHED THEN INSERT
(symbol, date, [open], high, low, [close], adj_close,
volume, dividends, stock_splits,
daily_return, intraday_range, sma_20, batch_id)
VALUES (@symbol, @date, @open, @high, @low, @close, @adj_close,
@volume, @dividends, @stock_splits,
@daily_return, @intraday_range, @sma_20, @batch_id);",
new {
symbol = row["symbol"].ToString(), date = (DateTime)row["date"],
open = Convert.ToDouble(row["open"]), high = Convert.ToDouble(row["high"]),
low = Convert.ToDouble(row["low"]), close = Convert.ToDouble(row["close"]),
adj_close = Convert.ToDouble(row["adj_close"]),
volume = Convert.ToInt64(row["volume"]),
dividends = Convert.ToDouble(row["dividends"]),
stock_splits = Convert.ToDouble(row["stock_splits"]),
daily_return = row["daily_return"] is DBNull ? (double?)null : Convert.ToDouble(row["daily_return"]),
intraday_range = row["intraday_range"] is DBNull ? (double?)null : Convert.ToDouble(row["intraday_range"]),
sma_20 = row["sma_20"] is DBNull ? (double?)null : Convert.ToDouble(row["sma_20"]),
batch_id = batchId,
});
}
return rowsAffected;
}
SQL Server — define quarantine persistence helper with Execute()
// Persists a rejected row to the quarantine table with its error message
void QuarantineRow(string batchId, string stage, Dictionary<string, object> rowData, string error)
{
sqlConn.Execute(
@"INSERT INTO quarantine (batch_id, stage, symbol, date, raw_data, error_message)
VALUES (@BatchId, @Stage, @Symbol, @Date, @RawData, @Error)",
new {
BatchId = batchId, Stage = stage,
Symbol = rowData.GetValueOrDefault("symbol")?.ToString(),
Date = rowData.ContainsKey("date") ? rowData["date"] : null,
RawData = JsonSerializer.Serialize(rowData),
Error = error.Length > 4000 ? error[..4000] : error,
});
}
Polly — define API retry wrapper with WaitAndRetryAsync() exponential backoff
Polly Retry Policy
Wraps API calls with 3 attempts and exponential backoff. Catches transient failures.
AsyncRetryPolicy retryPolicy = Policy
.Handle<HttpRequestException>()
.Or<TaskCanceledException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
onRetry: (ex, delay, attempt, _) =>
Console.WriteLine($" Retry {attempt}/3 after {ex.GetType().Name}")
);
Quality gates
Reusable assertion functions that check DataTable properties; run together as a gated pass/fail before each stage proceeds.
C# — define custom Exception subclass for quality gate failures
Quality Gate Exception
Raised when a data quality gate fails. Blocks downstream stages from processing bad data.
public class DataQualityException : Exception
{
public DataQualityException(string message) : base(message) { }
}
C# — assert DataTable is not empty with Rows.Count
Assert: Not Empty
Verifies the output table/frame is not empty after a stage.
(bool Passed, string Message) DqCheckNotEmpty(DataTable dt, string stage)
{
bool ok = dt.Rows.Count > 0;
return (ok, ok ? $"{stage}: {dt.Rows.Count} rows" : $"{stage}: EMPTY DataTable");
}
C# — assert no nulls in key columns with DBNull check
Assert: No Null Keys
Checks each key column individually, reports first failure found.
(bool Passed, string Message) DqCheckNoNullKeys(DataTable dt, IEnumerable<string> keys, string stage)
{
foreach (var col in keys)
{
if (!dt.Columns.Contains(col))
return (false, $"{stage}: column '{col}' missing");
int nulls = dt.AsEnumerable().Count(r => r[col] is DBNull || r[col] == null);
if (nulls > 0)
return (false, $"{stage}: {nulls} nulls in '{col}'");
}
return (true, $"{stage}: no null keys in [{string.Join(", ", keys)}]");
}
C# — assert no duplicate rows with GroupBy()
Assert: No Duplicates
Compares total rows vs unique key combinations to detect duplicates.
(bool Passed, string Message) DqCheckNoDuplicates(DataTable dt, IEnumerable<string> keys, string stage)
{
int total = dt.Rows.Count;
int unique = dt.AsEnumerable()
.GroupBy(r => string.Join("|", keys.Select(k => r[k]?.ToString() ?? "")))
.Count();
int dupes = total - unique;
bool ok = dupes == 0;
return (ok, ok ? $"{stage}: no duplicates" : $"{stage}: {dupes} duplicates on [{string.Join(", ", keys)}]");
}
C# — assert values within range with Where()
Assert: Value Range
Reports count of out-of-range values in the specified column.
(bool Passed, string Message) DqCheckRange(DataTable dt, string col, double minVal, double maxVal, string stage)
{
int outOfRange = dt.AsEnumerable()
.Where(r => !(r[col] is DBNull))
.Count(r => Convert.ToDouble(r[col]) < minVal || Convert.ToDouble(r[col]) > maxVal);
bool ok = outOfRange == 0;
return (ok, ok
? $"{stage}: '{col}' within range"
: $"{stage}: {outOfRange} values outside [{minVal}, {maxVal}] in '{col}'");
}
C# — assert data freshness against SLA with Max()
Assert: Data Freshness
Detects stale data that missed recent trading days.
(bool Passed, string Message) DqCheckFreshness(DataTable dt, string dateCol, int maxAgeDays, string stage)
{
if (dt.Rows.Count == 0)
return (false, $"{stage}: empty DataTable, can't check freshness");
var dates = dt.AsEnumerable()
.Where(r => !(r[dateCol] is DBNull))
.Select(r => Convert.ToDateTime(r[dateCol]))
.ToList();
if (dates.Count == 0)
return (false, $"{stage}: all dates are null");
var latest = dates.Max();
int age = (DateTime.Today - latest.Date).Days;
bool ok = age <= maxAgeDays;
return (ok, $"{stage}: latest date {latest:yyyy-MM-dd} ({age}d ago)" + (ok ? "" : $" EXCEEDS {maxAgeDays}d SLA"));
}
C# — assert minimum row count with Rows.Count
Assert: Minimum Row Count
Catches partial loads or missing symbols.
(bool Passed, string Message) DqCheckRowCount(DataTable dt, int minRows, string stage)
{
bool ok = dt.Rows.Count >= minRows;
return (ok, $"{stage}: {dt.Rows.Count} rows" + (ok ? "" : $" BELOW minimum {minRows}"));
}
Pipeline — run all quality gate assertions with Console.WriteLine()
Quality Gate Runner
Executes all checks for a stage, logs PASS/FAIL for each. : raises exception on first failure, blocking downstream stages.
DataTable RunQualityGate(IEnumerable<(bool Passed, string Message)> checks, string stage, bool failFast = true)
{
var dt = new DataTable();
dt.Columns.Add("check", typeof(string));
dt.Columns.Add("status", typeof(string));
bool allPassed = true;
foreach (var (passed, msg) in checks)
{
var status = passed ? "PASS" : "FAIL";
Console.WriteLine($" DQ {status}: {msg}");
dt.Rows.Add(msg, status);
if (!passed) allPassed = false;
}
if (!allPassed && failFast)
throw new DataQualityException($"Data quality gate FAILED for {stage}");
return dt;
}
5. Dimension Tables — Symbol Metadata (SCD2) & Trading Calendar
Dimensions are the pipeline’s external knowledge — facts about the world that the pipeline needs but doesn’t compute. dim_symbol uses SCD Type 2 because company metadata changes over time — without historization, a join between gold scores and dim_symbol shows today’s sector for historical dates, producing misleading analysis. dim_calendar exists because zero-volume doesn’t always mean bad data — the calendar tells the pipeline whether an exchange was open, turning undifferentiated zero-volume alerts into classified holidays vs genuine anomalies.
Without Trading Calendar
Every zero-volume day triggers an investigation. Good Friday, Christmas, local exchange holidays — all flagged as anomalies. An on-call engineer wastes time cross-referencing exchange schedules. With dim_calendar, the pipeline classifies each zero-volume date at ingestion and records the classification as a context warning.
With Trading Calendar
dim_calendarclassifies each zero-volume day as a known holiday or a genuine anomaly at ingestion time. Engineers see pre-classified context warnings — holiday days are acknowledged automatically, only true anomalies trigger alerts.
Symbol metadata (SCD2)
Fetch, load, and SCD Type 2 upsert company metadata from Yahoo Finance into dim_symbol.
HttpClient — fetch symbol metadata to JSON landing zone with GetStringAsync()
Fetch Symbol Metadata
Fetches company metadata from Yahoo Finance v8 API for each symbol. Saves raw API response to for replay.
string[] SCD2_COMPARE_COLS = { "company_name", "sector", "industry", "country", "exchange", "currency" };
async Task<string> FetchSymbolsToLanding(IEnumerable<string> symbols)
{
var client = new HttpClient();
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0");
var records = new List<Dictionary<string, object>>();
foreach (var symbol in symbols)
{
try
{
var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1d&range=1d";
var json = await retryPolicy.ExecuteAsync(() => client.GetStringAsync(url));
var doc = JsonDocument.Parse(json);
var meta = doc.RootElement
.GetProperty("chart").GetProperty("result")[0]
.GetProperty("meta");
var rec = new Dictionary<string, object>
{
["symbol"] = symbol,
["longName"] = meta.TryGetProperty("longName", out var ln) ? ln.GetString() : null,
["shortName"] = meta.TryGetProperty("shortName", out var sn) ? sn.GetString() : null,
["sector"] = null,
["sectorKey"] = null,
["industry"] = null,
["industryKey"] = null,
["country"] = null,
["city"] = null,
["website"] = null,
["longBusinessSummary"] = null,
["exchange"] = meta.TryGetProperty("exchangeName", out var ex) ? ex.GetString() : null,
["fullExchangeName"] = meta.TryGetProperty("fullExchangeName", out var fen) ? fen.GetString() : null,
["exchangeTimezoneName"] = meta.TryGetProperty("exchangeTimezoneName", out var etz) ? etz.GetString() : null,
["exchangeTimezoneShortName"] = meta.TryGetProperty("exchangeTimezoneShortName", out var etsn) ? etsn.GetString() : null,
["currency"] = meta.TryGetProperty("currency", out var cur) ? cur.GetString() : null,
["financialCurrency"] = meta.TryGetProperty("financialCurrency", out var fc) ? fc.GetString() : null,
["quoteType"] = meta.TryGetProperty("instrumentType", out var qt) ? qt.GetString() : null,
["market"] = meta.TryGetProperty("exchangeName", out var mk) ? mk.GetString() : null,
["marketCap"] = null,
};
records.Add(rec);
Console.WriteLine($" {symbol}: fetched ({rec["longName"]})");
}
catch (Exception e)
{
Console.WriteLine($" {symbol}: fetch failed ({e.Message}), using empty info");
records.Add(new Dictionary<string, object> { ["symbol"] = symbol });
}
}
var landingPath = Path.Combine(LANDING_DIR, "dim_symbol.json");
File.WriteAllText(landingPath, JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true }));
Console.WriteLine($"Landed: {landingPath} ({records.Count} symbols)");
return landingPath;
}
JSON — load symbol metadata from landing zone with JsonSerializer.Deserialize()
Load Symbols from Landing
Reads the JSON landing file and returns records ready for SCD2 upsert.
List<Dictionary<string, object>> LoadSymbolsFromLanding()
{
var landingPath = Path.Combine(LANDING_DIR, "dim_symbol.json");
var json = File.ReadAllText(landingPath);
var docs = JsonDocument.Parse(json).RootElement;
var results = new List<Dictionary<string, object>>();
foreach (var el in docs.EnumerateArray())
{
var dict = new Dictionary<string, object>();
foreach (var prop in el.EnumerateObject())
dict[prop.Name] = prop.Value.ValueKind == JsonValueKind.Null
? null : prop.Value.ToString();
results.Add(dict);
}
return results;
}
SQL Server — define SCD Type 2 upsert for one symbol with MERGE INTO
SCD Type 2 Dimension Upsert
New symbol → INSERT. Unchanged attributes → skip. Changed attributes → close old record, INSERT new version.
string Scd2UpsertSymbol(Dictionary<string, object> rec)
{
string Val(string key) => rec.TryGetValue(key, out var v) ? v?.ToString() : null;
string sector = Val("sector");
string industry = Val("industry");
var dbRec = new {
symbol = Val("symbol"),
company_name = Val("longName") ?? Val("shortName"),
short_name = Val("shortName"),
sector = sector,
sector_key = sector?.ToLower().Replace(" ", "_"),
industry = industry,
industry_key = industry?.ToLower().Replace(" ", "_"),
country = Val("country"),
city = Val("city"),
exchange = Val("exchange"),
full_exchange_name = Val("fullExchangeName"),
currency = Val("currency"),
market_cap = Val("marketCap") != null ? (long?)long.Parse(Val("marketCap")) : null,
website = Val("website"),
};
// Check current record
var existing = sqlConn.QueryFirstOrDefault<dynamic>(
@"SELECT id, company_name, sector, industry, country, exchange, currency
FROM dim_symbol WHERE symbol = @symbol AND is_current = 1",
new { dbRec.symbol });
if (existing == null)
{
sqlConn.Execute(
@"INSERT INTO dim_symbol
(symbol, company_name, short_name, sector, sector_key,
industry, industry_key, country, city, exchange,
full_exchange_name, currency, market_cap, website)
VALUES (@symbol, @company_name, @short_name, @sector, @sector_key,
@industry, @industry_key, @country, @city, @exchange,
@full_exchange_name, @currency, @market_cap, @website)",
dbRec);
return "INSERT";
}
// Compare tracked columns
var oldVals = (existing.company_name?.ToString(), existing.sector?.ToString(),
existing.industry?.ToString(), existing.country?.ToString(),
existing.exchange?.ToString(), existing.currency?.ToString());
var newVals = (dbRec.company_name, dbRec.sector, dbRec.industry,
dbRec.country, dbRec.exchange, dbRec.currency);
if (oldVals == newVals)
return "UNCHANGED";
// Attribute changed \u2192 close old record, insert new
sqlConn.Execute(
"UPDATE dim_symbol SET valid_to = SYSUTCDATETIME(), is_current = 0 WHERE id = @id",
new { id = (int)existing.id });
sqlConn.Execute(
@"INSERT INTO dim_symbol
(symbol, company_name, short_name, sector, sector_key,
industry, industry_key, country, city, exchange,
full_exchange_name, currency, market_cap, website)
VALUES (@symbol, @company_name, @short_name, @sector, @sector_key,
@industry, @industry_key, @country, @city, @exchange,
@full_exchange_name, @currency, @market_cap, @website)",
dbRec);
return "SCD2_UPDATE";
}
SQL Server — orchestrate SCD Type 2 upsert for all symbols with Execute()
SCD2 Upsert Orchestration
Read landing JSON, SCD2 upsert each symbol, log action taken per symbol.
DataTable PopulateDimSymbolFromLanding()
{
var records = LoadSymbolsFromLanding();
var dt = new DataTable();
dt.Columns.Add("symbol", typeof(string));
dt.Columns.Add("longName", typeof(string));
dt.Columns.Add("sector", typeof(string));
dt.Columns.Add("country", typeof(string));
dt.Columns.Add("exchange", typeof(string));
dt.Columns.Add("_action", typeof(string));
foreach (var rec in records)
{
string Val(string key) => rec.TryGetValue(key, out var v) ? v?.ToString() : null;
var action = Scd2UpsertSymbol(rec);
dt.Rows.Add(Val("symbol"), Val("longName"), Val("sector"),
Val("country"), Val("exchange"), action);
Console.WriteLine($" {Val("symbol")}: {action}");
}
return dt;
}
SQL Server — load symbols from landing and SCD2 upsert with MERGE INTO
// Step 1: Fetch from Yahoo Finance API \u2192 JSON landing zone
await FetchSymbolsToLanding(SYMBOLS);
// Step 2: Load from landing JSON \u2192 SCD2 upsert into dim_symbol
Console.WriteLine("\nStep 2: SCD2 upsert from landing zone...\n");
var dimSymbolDt = PopulateDimSymbolFromLanding();
dimSymbolDt.AsEnumerable().Take(5).CopyToDataTable()Step 1: Fetching symbol metadata to landing zone...
SAP.DE: fetched (SAP SE)
SIE.DE: fetched (Siemens Aktiengesellschaft)
ALV.DE: fetched (Allianz SE)
DTE.DE: fetched (Deutsche Telekom AG)
BAS.DE: fetched (BASF SE)
Landed: C:\Users\aperi\DEV\LANG\data\pipeline\landing\dim_symbol.json (5 symbols)
Step 2: SCD2 upsert from landing zone...
SAP.DE: UNCHANGED
SIE.DE: UNCHANGED
ALV.DE: UNCHANGED
DTE.DE: UNCHANGED
BAS.DE: UNCHANGED
| symbol | longName | sector | country | exchange | _action |
|---|---|---|---|---|---|
| SAP.DE | SAP SE | GER | UNCHANGED | ||
| SIE.DE | Siemens Aktiengesellschaft | GER | UNCHANGED | ||
| ALV.DE | Allianz SE | GER | UNCHANGED | ||
| DTE.DE | Deutsche Telekom AG | GER | UNCHANGED | ||
| BAS.DE | BASF SE | GER | UNCHANGED |
Trading calendar
Build and verify a per-exchange trading calendar, including holiday detection, using the dimension table populated by the Python notebook.
SQL Server — verify trading calendar exists with QueryToTable()
Read Existing Calendar
C# reads dim_calendar populated by the Python notebook rather than regenerating.
DataTable calDt = QueryToTable(
@"SELECT exchange_code, COUNT(*) as total_days,
SUM(CAST(is_trading_day AS INT)) as trading_days,
MIN(date) as first_date, MAX(date) as last_date
FROM dim_calendar GROUP BY exchange_code ORDER BY exchange_code");
calDt.AsEnumerable().Take(5).CopyToDataTable()Calendar dimension (from Python notebook):
| exchange_code | total_days | trading_days | first_date | last_date |
|---|---|---|---|---|
| GER | 732 | 506 | 28-Mar-24 0:00:00 | 29-Mar-26 0:00:00 |
SQL Server — persist calendar dimension with MERGE INTO
// Calendar already populated by Python notebook \u2014 verify row count
int calRows = sqlConn.ExecuteScalar<int>("SELECT COUNT(*) FROM dim_calendar");
Console.WriteLine($"dim_calendar: {calRows} rows verified");dim_calendar: 732 rows verified
SQL Server — display detected exchange holidays with QueryToTable()
// Display holidays detected (weekdays marked as non-trading)
DataTable holidays = QueryToTable(
@"SELECT TOP 20 date, exchange_code, day_of_week
FROM dim_calendar
WHERE day_of_week BETWEEN 1 AND 5 AND is_trading_day = 0
ORDER BY exchange_code, date");
int holidayCount = sqlConn.ExecuteScalar<int>(
@"SELECT COUNT(*) FROM dim_calendar
WHERE day_of_week BETWEEN 1 AND 5 AND is_trading_day = 0");
Console.WriteLine($"Holidays detected: {holidayCount} (weekdays with no trading)");
holidaysHolidays detected: 16 (weekdays with no trading)
| date | exchange_code | day_of_week |
|---|---|---|
| 29-Mar-24 0:00:00 | GER | 5 |
| 01-Apr-24 0:00:00 | GER | 1 |
| 01-May-24 0:00:00 | GER | 3 |
| 24-Dec-24 0:00:00 | GER | 2 |
| 25-Dec-24 0:00:00 | GER | 3 |
| 26-Dec-24 0:00:00 | GER | 4 |
| 31-Dec-24 0:00:00 | GER | 2 |
| 01-Jan-25 0:00:00 | GER | 3 |
| 18-Apr-25 0:00:00 | GER | 5 |
| 21-Apr-25 0:00:00 | GER | 1 |
| 01-May-25 0:00:00 | GER | 4 |
| 24-Dec-25 0:00:00 | GER | 3 |
| 25-Dec-25 0:00:00 | GER | 4 |
| 26-Dec-25 0:00:00 | GER | 5 |
| 31-Dec-25 0:00:00 | GER | 3 |
| 01-Jan-26 0:00:00 | GER | 4 |
6. Bronze Layer — Landing Zone + Incremental Ingestion
Bronze implements two principles. The landing zone decouples API fetching from database loading — API calls are unreliable and unrepeatable, so raw responses are saved as JSON files first. If the MERGE fails, data is still on disk. If the pipeline is replayed, it reads from files without re-calling the API. Contract enforcement at the bronze boundary is the first line of defense — business rule violations (high < low, negative prices, empty symbols) are caught here, not three stages later. Rejected rows go to the quarantine table with full error context — preserved for investigation and replay, never silently dropped.
Without Landing Zone
The pipeline calls the API and writes directly to SQL Server. The API changes its response format. The MERGE fails mid-batch. 3 of 5 symbols are loaded, 2 are missing, and there’s no way to replay because the API response is gone. With the landing zone, the raw JSON is on disk — fix the parser, re-run the load, no re-fetch needed.
With Landing Zone
Raw API responses are written to disk before any parsing or database operations. If the MERGE fails, re-run the load step against the saved files — no API call, no rate-limit risk, no missing symbols. The landing zone decouples the unreliable (API) from the recoverable (database).
Data fetching
Functions to download OHLCV data from the Yahoo Finance API to the JSON landing zone and reload it into a DataTable.
HttpClient — fetch OHLCV to JSON landing zone with GetStringAsync()
Landing Zone: OHLCV Fetch
Downloads OHLCV data from Yahoo Finance API and saves to . Each symbol gets its own file.
HttpClient httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0");
async Task<string> FetchOhlcvToLanding(string symbol, string start, string end)
{
long period1 = new DateTimeOffset(DateTime.Parse(start)).ToUnixTimeSeconds();
long period2 = new DateTimeOffset(DateTime.Parse(end)).ToUnixTimeSeconds();
string url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?period1={period1}&period2={period2}&interval=1d";
string json = await retryPolicy.ExecuteAsync(() => httpClient.GetStringAsync(url));
var safeSymbol = symbol.Replace(".", "_");
var landingPath = Path.Combine(LANDING_DIR, $"ohlcv_{safeSymbol}.json");
// Parse v8 response and extract OHLCV records
using var doc = JsonDocument.Parse(json);
var result = doc.RootElement.GetProperty("chart").GetProperty("result")[0];
var timestamps = result.GetProperty("timestamp");
var quote = result.GetProperty("indicators").GetProperty("quote")[0];
var adjClose = result.GetProperty("indicators").GetProperty("adjclose")[0].GetProperty("adjclose");
var records = new List<Dictionary<string, object>>();
for (int i = 0; i < timestamps.GetArrayLength(); i++)
{
var ts = DateTimeOffset.FromUnixTimeSeconds(timestamps[i].GetInt64()).DateTime;
records.Add(new Dictionary<string, object> {
["symbol"] = symbol,
["date"] = ts.ToString("yyyy-MM-dd"),
["open"] = quote.GetProperty("open")[i].GetDouble(),
["high"] = quote.GetProperty("high")[i].GetDouble(),
["low"] = quote.GetProperty("low")[i].GetDouble(),
["close"] = quote.GetProperty("close")[i].GetDouble(),
["adj_close"] = adjClose[i].GetDouble(),
["volume"] = quote.GetProperty("volume")[i].GetInt64(),
["dividends"] = 0.0,
["stock_splits"] = 0.0,
});
}
File.WriteAllText(landingPath, JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true }));
return landingPath;
}
C# — load OHLCV from JSON landing zone with JsonSerializer.Deserialize()
Load OHLCV from Landing
Reads a symbol JSON landing file into a DataTable. Casts date strings to DateTime.
DataTable LoadOhlcvFromLanding(string symbol)
{
var safeSymbol = symbol.Replace(".", "_");
var landingPath = Path.Combine(LANDING_DIR, $"ohlcv_{safeSymbol}.json");
if (!File.Exists(landingPath)) return new DataTable();
var json = File.ReadAllText(landingPath);
var records = JsonSerializer.Deserialize<List<Dictionary<string, JsonElement>>>(json);
if (records == null || records.Count == 0) return new DataTable();
var dt = new DataTable();
dt.Columns.Add("symbol", typeof(string));
dt.Columns.Add("date", typeof(DateTime));
dt.Columns.Add("open", typeof(double));
dt.Columns.Add("high", typeof(double));
dt.Columns.Add("low", typeof(double));
dt.Columns.Add("close", typeof(double));
dt.Columns.Add("adj_close", typeof(double));
dt.Columns.Add("volume", typeof(long));
dt.Columns.Add("dividends", typeof(double));
dt.Columns.Add("stock_splits", typeof(double));
foreach (var rec in records)
{
dt.Rows.Add(
rec["symbol"].GetString(),
DateTime.Parse(rec["date"].GetString()),
rec["open"].GetDouble(),
rec["high"].GetDouble(),
rec["low"].GetDouble(),
rec["close"].GetDouble(),
rec["adj_close"].GetDouble(),
rec["volume"].GetInt64(),
rec.ContainsKey("dividends") ? rec["dividends"].GetDouble() : 0.0,
rec.ContainsKey("stock_splits") ? rec["stock_splits"].GetDouble() : 0.0
);
}
return dt;
}
HttpClient — test single symbol landing zone fetch with FetchOhlcvToLanding()
Verifies the landing zone pattern: fetch → JSON → load → DataTable.
var testPath = await FetchOhlcvToLanding("SAP.DE", "2024-06-01", "2024-06-30");
if (testPath != null)
Console.WriteLine($"Landed: {Path.GetFileName(testPath)} ({new FileInfo(testPath).Length / 1024.0:F1} KB)");
var testDt = LoadOhlcvFromLanding("SAP.DE");
Console.WriteLine($"Loaded: {testDt.Rows.Count} rows, columns: {string.Join(", ", testDt.Columns.Cast<DataColumn>().Select(c => c.ColumnName))}");
testDt.AsEnumerable().Take(5).CopyToDataTable()Landed: ohlcv_SAP_DE.json (5.7 KB)
Loaded: 20 rows, columns: symbol, date, open, high, low, close, adj_close, volume, dividends, stock_splits
| symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits |
|---|---|---|---|---|---|---|---|---|---|
| SAP.DE | 03-Jun-24 0:00:00 | 169.74000549316406 | 169.82000732421875 | 166.9600067138672 | 168.25999450683594 | 166.7528076171875 | 1531728 | 0 | 0 |
| SAP.DE | 04-Jun-24 0:00:00 | 168.52000427246094 | 170.44000244140625 | 167.66000366210938 | 168.60000610351562 | 167.0897674560547 | 1592071 | 0 | 0 |
| SAP.DE | 05-Jun-24 0:00:00 | 170 | 171.82000732421875 | 169.0800018310547 | 171.52000427246094 | 169.98361206054688 | 1352916 | 0 | 0 |
| SAP.DE | 06-Jun-24 0:00:00 | 176.02000427246094 | 180.24000549316406 | 176 | 177.72000122070312 | 176.12806701660156 | 2089549 | 0 | 0 |
| SAP.DE | 07-Jun-24 0:00:00 | 177.5 | 178.25999450683594 | 175.6999969482422 | 177.36000061035156 | 175.77130126953125 | 1224863 | 0 | 0 |
Validation
Row-level FluentValidation at the Bronze boundary; rejected rows are routed to the quarantine table.
FluentValidation — validate Bronze rows with Validate() row-level check
Bronze Row-Level Validation
Valid rows collected; rejected rows go to quarantine with error details.
(DataTable Valid, int Rejected) ValidateBronze(DataTable dt, string batchId = "")
{
var validator = new RawOhlcvValidator();
var valid = dt.Clone();
int rejected = 0;
foreach (DataRow row in dt.Rows)
{
try
{
var record = new RawOhlcv(
row["symbol"].ToString(), (DateTime)row["date"],
Convert.ToDouble(row["open"]), Convert.ToDouble(row["high"]),
Convert.ToDouble(row["low"]), Convert.ToDouble(row["close"]),
Convert.ToDouble(row["adj_close"]), Convert.ToInt64(row["volume"]),
Convert.ToDouble(row["dividends"]), Convert.ToDouble(row["stock_splits"]));
var result = validator.Validate(record);
if (!result.IsValid) throw new Exception(string.Join("; ", result.Errors));
valid.ImportRow(row);
}
catch (Exception ex)
{
rejected++;
if (!string.IsNullOrEmpty(batchId))
QuarantineRow(batchId, "bronze",
dt.Columns.Cast<DataColumn>().ToDictionary(c => c.ColumnName, c => row[c] as object),
ex.Message);
}
}
return (valid, rejected);
}
FluentValidation — test Bronze validation on sample data
Should pass all rows since Yahoo Finance data is generally clean.
var (validDt, rejectedCount) = ValidateBronze(testDt, "test");
Console.WriteLine($"Valid: {validDt.Rows.Count} rows | Rejected: {rejectedCount} rows");
var preview = validDt.AsEnumerable().Take(5).CopyToDataTable();
previewValid: 20 rows | Rejected: 0 rows
| symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits |
|---|---|---|---|---|---|---|---|---|---|
| SAP.DE | 03-Jun-24 0:00:00 | 169.74000549316406 | 169.82000732421875 | 166.9600067138672 | 168.25999450683594 | 166.7528076171875 | 1531728 | 0 | 0 |
| SAP.DE | 04-Jun-24 0:00:00 | 168.52000427246094 | 170.44000244140625 | 167.66000366210938 | 168.60000610351562 | 167.0897674560547 | 1592071 | 0 | 0 |
| SAP.DE | 05-Jun-24 0:00:00 | 170 | 171.82000732421875 | 169.0800018310547 | 171.52000427246094 | 169.98361206054688 | 1352916 | 0 | 0 |
| SAP.DE | 06-Jun-24 0:00:00 | 176.02000427246094 | 180.24000549316406 | 176 | 177.72000122070312 | 176.12806701660156 | 2089549 | 0 | 0 |
| SAP.DE | 07-Jun-24 0:00:00 | 177.5 | 178.25999450683594 | 175.6999969482422 | 177.36000061035156 | 175.77130126953125 | 1224863 | 0 | 0 |
Ingestion pipeline
End-to-end Bronze orchestration: incremental fetch, FluentValidation, MERGE upsert, and quality gate.
Bronze — define incremental ingestion pipeline with landing zone + MERGE INTO
Bronze Ingestion Pipeline
Three-step process: land → validate → MERGE upsert. Checks last known date per symbol, fetches only new data, validates with FluentValidation, MERGE upserts to SQL Server.
async Task<(DataTable, StageLineage)> IngestBronze(string[] symbols, string start, string end, string batchId)
{
var stageCtx = StartStage(batchId, "bronze", 0);
int totalFetched = 0;
int totalRejected = 0;
var allRows = new DataTable();
foreach (var symbol in symbols)
{
// Check last known date in SQL Server
var lastDate = sqlConn.ExecuteScalar<DateTime?>(
"SELECT MAX(date) FROM bronze_ohlcv WHERE symbol = @symbol",
new { symbol });
// Determine fetch range
string fetchStart;
if (lastDate.HasValue)
{
fetchStart = lastDate.Value.AddDays(1).ToString("yyyy-MM-dd");
if (string.Compare(fetchStart, end) >= 0)
{
Console.WriteLine($" {symbol}: up to date (last: {lastDate.Value:yyyy-MM-dd})");
continue;
}
}
else
{
fetchStart = start;
}
// Step 1: Fetch from Yahoo Finance → JSON landing zone
string landingPath;
try
{
landingPath = await FetchOhlcvToLanding(symbol, fetchStart, end);
}
catch
{
Console.WriteLine($" {symbol}: no new data from {fetchStart}");
continue;
}
// Step 2: Load from landing zone
var rawDt = LoadOhlcvFromLanding(symbol);
totalFetched += rawDt.Rows.Count;
// Step 3: Validate through FluentValidation
var (validDt, rejected) = ValidateBronze(rawDt, batchId);
totalRejected += rejected;
if (validDt.Rows.Count > 0)
{
if (allRows.Columns.Count == 0) allRows = validDt.Clone();
foreach (DataRow r in validDt.Rows) allRows.ImportRow(r);
// MERGE upsert into SQL Server
int merged = MergeBronze(validDt, batchId);
double sizeKb = new FileInfo(landingPath).Length / 1024.0;
Console.WriteLine($" {symbol}: {rawDt.Rows.Count} landed ({sizeKb:F1} KB), "
+ $"{validDt.Rows.Count} valid, {rejected} rejected, {merged} merged");
}
}
// Build lineage via StartStage/EndStage
stageCtx["input_rows"] = totalFetched;
var emptyDt = allRows.Rows.Count > 0 ? allRows : new DataTable();
if (emptyDt.Columns.Count == 0) { emptyDt.Columns.Add("_"); emptyDt.Rows.Add("_"); }
var lineage = EndStage(stageCtx, emptyDt, totalRejected);
PersistLineage(lineage);
// Return FULL bronze dataset for downstream stages
var bronzeFull = QueryToTable(
"SELECT symbol, date, [open], high, low, [close], "
+ "adj_close, volume, dividends, stock_splits, batch_id "
+ "FROM bronze_ohlcv ORDER BY symbol, date");
return (bronzeFull, lineage);
}
Bronze — execute incremental ingestion for all symbols
Bronze Execution with Context
Creates (scheduled, T-1) and , initializes , then runs the Bronze ingestion pipeline.
var batchId = Guid.NewGuid().ToString();
var bizCtx = new BusinessContext {
Trigger = "scheduled",
BusinessDate = DateTime.Today.AddDays(-1),
};
var tempCtx = new TemporalContext {
AsOfDate = DateTime.Today.AddDays(-1),
ReportingPeriodStart = DateTime.Parse(START_DATE),
ReportingPeriodEnd = DateTime.Parse(END_DATE),
Timezone = "CET",
};
var bronzeStageCtx = new StageContext {
BatchId = batchId, Stage = "bronze",
ColumnCtx = BRONZE_COLUMNS,
BizContext = bizCtx, TempContext = tempCtx,
};
Console.WriteLine($"Pipeline batch_id: {batchId.Substring(0, 8)}...");
Console.WriteLine($"Range: {START_DATE} → {END_DATE}");
var sw = System.Diagnostics.Stopwatch.StartNew();
var (bronzeDt, bronzeLineage) = await IngestBronze(SYMBOLS, START_DATE, END_DATE, batchId);
sw.Stop();
// Detect zero-volume rows and classify using trading calendar
var zeroVol = bronzeDt.AsEnumerable()
.Where(r => Convert.ToInt64(r["volume"]) == 0)
.Select(r => new { Symbol = r["symbol"].ToString(), Date = (DateTime)r["date"] })
.Distinct().ToList();
if (zeroVol.Count > 0)
{
foreach (var row in zeroVol)
{
var cal = QueryToTable(
Console.WriteLine($"SELECT is_trading_day FROM dim_calendar ");
+ $"WHERE date = '{row.Date:yyyy-MM-dd}' AND exchange_code = 'XETR'");
if (cal.Rows.Count > 0 && !Convert.ToBoolean(cal.Rows[0]["is_trading_day"]))
bronzeStageCtx.AddWarning(
$"{row.Symbol}: zero volume on {row.Date:yyyy-MM-dd} — non-trading day (calendar)");
else if (cal.Rows.Count > 0 && Convert.ToBoolean(cal.Rows[0]["is_trading_day"]))
bronzeStageCtx.AddWarning(
$"{row.Symbol}: zero volume on {row.Date:yyyy-MM-dd} — TRADING DAY (anomaly)");
}
}
PersistContext(bronzeStageCtx);
var silverStageCtx = bronzeStageCtx.ForNextStage("silver", bronzeLineage, SILVER_COLUMNS);
Console.WriteLine($"Bronze complete: {bronzeDt.Rows.Count} rows in {sw.ElapsedMilliseconds}ms");Pipeline batch_id: d99b77ff...
Range: 2024-03-30 → 2026-03-30
SAP.DE: 1 landed (0.3 KB), 1 valid, 0 rejected, 1 merged
SIE.DE: 1 landed (0.3 KB), 1 valid, 0 rejected, 1 merged
ALV.DE: 1 landed (0.3 KB), 1 valid, 0 rejected, 1 merged
DTE.DE: 1 landed (0.3 KB), 1 valid, 0 rejected, 1 merged
BAS.DE: 1 landed (0.3 KB), 1 valid, 0 rejected, 1 merged
Bronze complete: 2530 rows in 583ms
SQL Server — display Bronze sample data with QueryToTable()
// Show first rows of ingested data to verify schema and values
QueryToTable("SELECT TOP 5 * FROM bronze_ohlcv ORDER BY symbol, date")| id | symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits | batch_id | ingested_at |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1013 | ALV.DE | 28-Mar-24 0:00:00 | 277 | 278.1000061035156 | 276.45001220703125 | 277.79998779296875 | 252.8253936767578 | 919173 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | 28-Mar-26 23:21:34 |
| 1014 | ALV.DE | 02-Apr-24 0:00:00 | 278.20001220703125 | 280 | 272.20001220703125 | 273.8999938964844 | 249.2760009765625 | 1013176 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | 28-Mar-26 23:21:34 |
| 1015 | ALV.DE | 03-Apr-24 0:00:00 | 274.5 | 276.6000061035156 | 273.8999938964844 | 274.3999938964844 | 249.73106384277344 | 782102 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | 28-Mar-26 23:21:34 |
| 1016 | ALV.DE | 04-Apr-24 0:00:00 | 274.1000061035156 | 275.20001220703125 | 272.20001220703125 | 272.3999938964844 | 247.91087341308594 | 690551 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | 28-Mar-26 23:21:34 |
| 1017 | ALV.DE | 05-Apr-24 0:00:00 | 270 | 270.20001220703125 | 267.1000061035156 | 268.79998779296875 | 244.63449096679688 | 930874 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | 28-Mar-26 23:21:34 |
SQL Server — display Bronze row counts per symbol with GROUP BY
Verifies all symbols were ingested with reasonable row counts.
QueryToTable(@"SELECT symbol, COUNT(*) as rows, MIN(date) as first_date, MAX(date) as last_date
FROM bronze_ohlcv GROUP BY symbol ORDER BY symbol")| symbol | rows | first_date | last_date |
|---|---|---|---|
| ALV.DE | 506 | 28-Mar-24 0:00:00 | 27-Mar-26 0:00:00 |
| BAS.DE | 506 | 28-Mar-24 0:00:00 | 27-Mar-26 0:00:00 |
| DTE.DE | 506 | 28-Mar-24 0:00:00 | 27-Mar-26 0:00:00 |
| SAP.DE | 506 | 28-Mar-24 0:00:00 | 27-Mar-26 0:00:00 |
| SIE.DE | 506 | 28-Mar-24 0:00:00 | 27-Mar-26 0:00:00 |
Pipeline — run Bronze data quality gate with RunQualityGate()
Bronze Quality Gate
All checks must pass before Silver processing begins.
var bronzeDq = RunQualityGate(new[] {
DqCheckNotEmpty(bronzeDt, "bronze"),
DqCheckNoNullKeys(bronzeDt, new[] { "symbol", "date" }, "bronze"),
DqCheckNoDuplicates(bronzeDt, new[] { "symbol", "date" }, "bronze"),
DqCheckRange(bronzeDt, "close", 0.01, 100_000, "bronze"),
DqCheckRange(bronzeDt, "volume", 0, 10_000_000_000, "bronze"),
DqCheckFreshness(bronzeDt, "date", 5, "bronze"),
DqCheckRowCount(bronzeDt, SYMBOLS.Length * 200, "bronze"),
}, "bronze");
bronzeDqDQ PASS: bronze: 2530 rows
DQ PASS: bronze: no null keys in [symbol, date]
DQ PASS: bronze: no duplicates
DQ PASS: bronze: 'close' within range
DQ PASS: bronze: 'volume' within range
DQ PASS: bronze: latest date 2026-03-27 (3d ago)
DQ PASS: bronze: 2530 rows
| check | status |
|---|---|
| bronze: 2530 rows | PASS |
| bronze: no null keys in [symbol, date] | PASS |
| bronze: no duplicates | PASS |
| bronze: 'close' within range | PASS |
| bronze: 'volume' within range | PASS |
| bronze: latest date 2026-03-27 (3d ago) | PASS |
| bronze: 2530 rows | PASS |
7. Silver Layer — Cleaning & Enrichment
Silver is where the Functional Core principle (Gary Bernhardt, ‘Boundaries’ 2012) is most visible. The three transforms (daily_return, intraday_range, sma_20) are pure functions — DataFrame in, DataFrame out, no database calls, no file I/O, no side effects. Pure functions are trivially testable (pass a 10-row hardcoded DataFrame, assert the output), trivially debuggable (the bug is in the formula, not in a network timeout), and trivially parallelizable (no shared state). The imperative shell (MERGE upsert, lineage persistence, context propagation) wraps AROUND the pure transforms, never inside them.
Without Pure Transforms
A transform function that reads from SQL Server mid-computation becomes untestable without a live database. A transform that writes intermediate results to a file fails unpredictably under disk pressure. Keeping transforms pure means the only thing that can go wrong is the formula — and formulas can be verified with a unit test in milliseconds.
With Pure Transforms
Each silver transform is a function:
DataTable in → DataTable out, no I/O, no side effects. Unit tests pass a 10-row hardcodedDataTableand assert the output in milliseconds — no database, no network, no flakiness. The imperative shell (MERGE, lineage, context) wraps around pure transforms, never inside them.
Transform functions
Pure LINQ functions computing daily return, intraday range, and 20-day SMA — no side effects.
LINQ — compute daily returns with grouped percentage change
Transform: Daily Returns
Close-to-close percentage change, partitioned by symbol. Pure function: DataTable in → DataTable out.
DataTable ComputeDailyReturns(DataTable dt)
{
if (!dt.Columns.Contains("daily_return"))
dt.Columns.Add("daily_return", typeof(double));
var groups = dt.AsEnumerable().GroupBy(r => r.Field<string>("symbol"));
foreach (var group in groups)
{
var rows = group.OrderBy(r => r.Field<DateTime>("date")).ToList();
for (int i = 0; i < rows.Count; i++)
{
if (i == 0)
rows[i]["daily_return"] = 0.0;
else
{
double prevClose = Convert.ToDouble(rows[i - 1]["close"]);
double currClose = Convert.ToDouble(rows[i]["close"]);
rows[i]["daily_return"] = Math.Round((currClose - prevClose) / prevClose, 6);
}
}
}
return dt;
}
// Test on Bronze data
var testReturns = ComputeDailyReturns(bronzeDt.Copy());
var sapRows = testReturns.AsEnumerable()
.Where(r => r.Field<string>("symbol") == "SAP.DE")
.Take(5);
var preview = testReturns.Clone();
foreach (var r in sapRows) preview.ImportRow(r);
preview| symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits | batch_id | daily_return |
|---|---|---|---|---|---|---|---|---|---|---|---|
| SAP.DE | 28-Mar-24 0:00:00 | 181.82000732421875 | 181.86000061035156 | 179.10000610351562 | 180.4600067138672 | 176.6092529296875 | 1700825 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | 0 |
| SAP.DE | 02-Apr-24 0:00:00 | 181 | 181.9199981689453 | 177.05999755859375 | 177.05999755859375 | 173.28179931640625 | 1838833 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | -0.018841 |
| SAP.DE | 03-Apr-24 0:00:00 | 178.32000732421875 | 179.52000427246094 | 176.55999755859375 | 178.22000122070312 | 174.41705322265625 | 1501774 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | 0.006551 |
| SAP.DE | 04-Apr-24 0:00:00 | 177.9199981689453 | 178.4600067138672 | 176.33999633789062 | 178.02000427246094 | 174.22132873535156 | 1126985 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | -0.001122 |
| SAP.DE | 05-Apr-24 0:00:00 | 175.39999389648438 | 177.9600067138672 | 174.77999877929688 | 177.4199981689453 | 173.63412475585938 | 2099406 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | -0.00337 |
LINQ — compute intraday range with (high - low) / close
Transform: Intraday Range
— normalized daily price spread. Higher values = more volatile day.
DataTable ComputeIntradayRange(DataTable dt)
{
if (!dt.Columns.Contains("intraday_range"))
dt.Columns.Add("intraday_range", typeof(double));
foreach (DataRow row in dt.Rows)
{
double high = Convert.ToDouble(row["high"]);
double low = Convert.ToDouble(row["low"]);
double close = Convert.ToDouble(row["close"]);
row["intraday_range"] = Math.Round((high - low) / close, 6);
}
return dt;
}
// Test on returns data
var testRange = ComputeIntradayRange(testReturns);
var sapRange = testRange.AsEnumerable()
.Where(r => r.Field<string>("symbol") == "SAP.DE")
.Take(5);
var previewRange = testRange.Clone();
foreach (var r in sapRange) previewRange.ImportRow(r);
previewRange| symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits | batch_id | daily_return | intraday_range |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| SAP.DE | 28-Mar-24 0:00:00 | 181.82000732421875 | 181.86000061035156 | 179.10000610351562 | 180.4600067138672 | 176.6092529296875 | 1700825 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | 0 | 0.015294 |
| SAP.DE | 02-Apr-24 0:00:00 | 181 | 181.9199981689453 | 177.05999755859375 | 177.05999755859375 | 173.28179931640625 | 1838833 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | -0.018841 | 0.027448 |
| SAP.DE | 03-Apr-24 0:00:00 | 178.32000732421875 | 179.52000427246094 | 176.55999755859375 | 178.22000122070312 | 174.41705322265625 | 1501774 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | 0.006551 | 0.016609 |
| SAP.DE | 04-Apr-24 0:00:00 | 177.9199981689453 | 178.4600067138672 | 176.33999633789062 | 178.02000427246094 | 174.22132873535156 | 1126985 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | -0.001122 | 0.011909 |
| SAP.DE | 05-Apr-24 0:00:00 | 175.39999389648438 | 177.9600067138672 | 174.77999877929688 | 177.4199981689453 | 173.63412475585938 | 2099406 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | -0.00337 | 0.017924 |
LINQ — compute 20-day moving average with rolling window
Transform: 20-Day SMA
Rolling mean of close price over 20-day window, per symbol. First 19 rows per symbol are NULL (insufficient data).
DataTable ComputeSma(DataTable dt, int window = 20)
{
string col = $"sma_{window}";
if (!dt.Columns.Contains(col))
dt.Columns.Add(col, typeof(object));
var groups = dt.AsEnumerable().GroupBy(r => r.Field<string>("symbol"));
foreach (var group in groups)
{
var rows = group.OrderBy(r => r.Field<DateTime>("date")).ToList();
for (int i = 0; i < rows.Count; i++)
{
if (i < window - 1)
rows[i][col] = DBNull.Value;
else
{
double sum = 0;
for (int j = i - window + 1; j <= i; j++)
sum += Convert.ToDouble(rows[j]["close"]);
rows[i][col] = Math.Round(sum / window, 4);
}
}
}
return dt;
}
// Test on range data
var testSma = ComputeSma(testRange);
var sapSma = testSma.AsEnumerable()
.Where(r => r.Field<string>("symbol") == "SAP.DE")
.Reverse().Take(5).Reverse();
var previewSma = testSma.Clone();
foreach (var r in sapSma) previewSma.ImportRow(r);
previewSma| symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits | batch_id | daily_return | intraday_range | sma_20 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| SAP.DE | 23-Mar-26 0:00:00 | 150.4600067138672 | 161.52000427246094 | 150.39999389648438 | 153.86000061035156 | 153.86000061035156 | 4165368 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | 0.00026 | 0.072274 | 166.034 |
| SAP.DE | 24-Mar-26 0:00:00 | 149.75999450683594 | 151.0399932861328 | 146 | 147.6199951171875 | 147.6199951171875 | 4380715 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | -0.040556 | 0.034142 | 165.123 |
| SAP.DE | 25-Mar-26 0:00:00 | 148.77999877929688 | 150.5399932861328 | 145.36000061035156 | 146.89999389648438 | 146.89999389648438 | 3757697 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | -0.004877 | 0.035262 | 164.129 |
| SAP.DE | 26-Mar-26 0:00:00 | 145.39999389648438 | 148.0800018310547 | 143.52000427246094 | 144.63999938964844 | 144.63999938964844 | 3752998 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | -0.015385 | 0.031527 | 162.75 |
| SAP.DE | 27-Mar-26 0:00:00 | 145.74000549316406 | 147.32000732421875 | 142.10000610351562 | 142.55999755859375 | 142.55999755859375 | 3568581 | 0 | 0 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f | -0.014381 | 0.036616 | 161.33 |
C# — compose all Silver transforms with function chaining
Transform Composition Pipeline
Chains three pure functions — each is independent and unit-testable. The composed pipeline validates through FluentValidation before MERGE.
DataTable TransformSilver(DataTable bronzeDt)
{
var dt = bronzeDt.Copy();
if (dt.Columns.Contains("batch_id")) dt.Columns.Remove("batch_id");
dt = ComputeDailyReturns(dt);
dt = ComputeIntradayRange(dt);
dt = ComputeSma(dt, 20);
return dt;
}
FluentValidation — validate Silver rows with Validate() row-level check
Silver Row-Level Validation
Valid rows collected; rejected rows quarantined with error details.
(DataTable Valid, int Rejected) ValidateSilver(DataTable dt, string batchId)
{
var validator = new CleanOhlcvValidator();
var valid = dt.Clone();
int rejected = 0;
foreach (DataRow row in dt.Rows)
{
try
{
var sma20 = row["sma_20"] == DBNull.Value ? (double?)null : Convert.ToDouble(row["sma_20"]);
var record = new CleanOhlcv(
row["symbol"].ToString(), (DateTime)row["date"],
Convert.ToDouble(row["open"]), Convert.ToDouble(row["high"]),
Convert.ToDouble(row["low"]), Convert.ToDouble(row["close"]),
Convert.ToDouble(row["adj_close"]), Convert.ToInt64(row["volume"]),
Convert.ToDouble(row["dividends"]), Convert.ToDouble(row["stock_splits"]),
Convert.ToDouble(row["daily_return"]),
Convert.ToDouble(row["intraday_range"]),
sma20, batchId);
var result = validator.Validate(record);
if (!result.IsValid) throw new Exception(string.Join("; ", result.Errors));
valid.ImportRow(row);
}
catch (Exception ex)
{
rejected++;
QuarantineRow(batchId, "silver",
dt.Columns.Cast<DataColumn>().ToDictionary(c => c.ColumnName, c => row[c] as object),
ex.Message);
}
}
return (valid, rejected);
}
Enrichment pipeline
Imperative shell: applies transforms, validates through CleanOhlcv, MERGE upserts to SQL Server, and runs the quality gate.
Silver — define enrichment pipeline with transform + MERGE INTO
Silver Enrichment Pipeline
Orchestrates: transform (pure) → validate (FluentValidation) → MERGE (SQL) → lineage. Transforms the FULL bronze dataset (needed for correct SMA/returns).
(DataTable, StageLineage) ProcessSilver(DataTable bronzeDt, string batchId)
{
var stageCtx = StartStage(batchId, "silver", bronzeDt.Rows.Count);
// Apply all transforms on full bronze (SMA/returns need full history)
var enrichedDt = TransformSilver(bronzeDt);
// Validate through FluentValidation
var (validDt, rejected) = ValidateSilver(enrichedDt, batchId);
// MERGE upsert into SQL Server
if (validDt.Rows.Count > 0)
{
int merged = MergeSilver(validDt, batchId);
Console.WriteLine($"Silver: {merged} rows merged ({validDt.Rows.Count} valid, {rejected} rejected)");
}
// Complete lineage
var lineage = EndStage(stageCtx, validDt, rejected);
PersistLineage(lineage);
// Return FULL silver dataset for Gold layer
var silverFull = QueryToTable(
"SELECT symbol, date, [open], high, low, [close], "
+ "adj_close, volume, dividends, stock_splits, "
+ "daily_return, intraday_range, sma_20, batch_id "
+ "FROM silver_ohlcv ORDER BY symbol, date");
return (silverFull, lineage);
}
Silver — execute enrichment on full Bronze data
Silver Execution with Context
Runs the Silver enrichment, then records SMA-20 null warnings in for downstream visibility.
var swSilver = System.Diagnostics.Stopwatch.StartNew();
var (silverDt, silverLineage) = ProcessSilver(bronzeDt, batchId);
swSilver.Stop();
int smaNullCount = silverDt.AsEnumerable()
.Count(r => r["sma_20"] == DBNull.Value || r["sma_20"] == null);
if (smaNullCount > 0)
silverStageCtx.AddWarning($"sma_20: {smaNullCount} NULL values (first 19 rows per symbol)");
PersistContext(silverStageCtx);
var goldStageCtx = silverStageCtx.ForNextStage("gold", silverLineage, GOLD_DAILY_COLUMNS);
Console.WriteLine($"Silver complete: {silverDt.Rows.Count} rows in {swSilver.ElapsedMilliseconds}ms");Silver: 2530 rows merged (2530 valid, 0 rejected)
WARNING silver: sma_20: 95 NULL values (first 19 rows per symbol)
Silver complete: 2530 rows in 5837ms
SQL Server — display Silver enriched columns with QueryToTable()
Verifies that daily_return, intraday_range, and sma_20 are populated after enrichment.
QueryToTable(@"SELECT TOP 5 symbol, date, [close], daily_return, intraday_range, sma_20
FROM silver_ohlcv WHERE symbol = 'SAP.DE' ORDER BY date DESC")| symbol | date | close | daily_return | intraday_range | sma_20 |
|---|---|---|---|---|---|
| SAP.DE | 27-Mar-26 0:00:00 | 142.55999755859375 | -0.014381 | 0.036616 | 161.33 |
| SAP.DE | 26-Mar-26 0:00:00 | 144.63999938964844 | -0.015385 | 0.031527 | 162.75 |
| SAP.DE | 25-Mar-26 0:00:00 | 146.89999389648438 | -0.004877 | 0.035262 | 164.129 |
| SAP.DE | 24-Mar-26 0:00:00 | 147.6199951171875 | -0.040556 | 0.034142 | 165.123 |
| SAP.DE | 23-Mar-26 0:00:00 | 153.86000061035156 | 0.00026 | 0.072274 | 166.034 |
SQL Server — display Silver statistics per symbol with GROUP BY
// Summary stats to verify enrichment quality across all symbols
QueryToTable(@"SELECT symbol,
ROUND(AVG(daily_return), 6) as avg_return,
ROUND(STDEV(daily_return), 6) as volatility,
ROUND(AVG(intraday_range), 6) as avg_intraday,
SUM(CASE WHEN sma_20 IS NULL THEN 1 ELSE 0 END) as sma_nulls,
COUNT(*) as rows
FROM silver_ohlcv GROUP BY symbol ORDER BY symbol")| symbol | avg_return | volatility | avg_intraday | sma_nulls | rows |
|---|---|---|---|---|---|
| ALV.DE | 0.000532 | 0.011851 | 0.014106 | 19 | 506 |
| BAS.DE | 0.000121 | 0.017508 | 0.021398 | 19 | 506 |
| DTE.DE | 0.000765 | 0.013263 | 0.01572 | 19 | 506 |
| SAP.DE | -0.000285 | 0.018919 | 0.020672 | 19 | 506 |
| SIE.DE | 0.000475 | 0.019223 | 0.0215 | 19 | 506 |
Pipeline — run Silver data quality gate with RunQualityGate()
Silver Quality Gate
Hard gate: blocks pipeline on structural issues. Soft gate: logs warnings on statistical anomalies (e.g., return outliers).
var silverDq = RunQualityGate(new[] {
DqCheckNotEmpty(silverDt, "silver"),
DqCheckNoNullKeys(silverDt, new[] { "symbol", "date", "daily_return" }, "silver"),
DqCheckNoDuplicates(silverDt, new[] { "symbol", "date" }, "silver"),
DqCheckRange(silverDt, "daily_return", -0.5, 0.5, "silver"),
DqCheckRange(silverDt, "intraday_range", 0, 0.5, "silver"),
DqCheckFreshness(silverDt, "date", 5, "silver"),
DqCheckRowCount(silverDt, SYMBOLS.Length * 200, "silver"),
}, "silver");
// Soft checks — warn but don't block (daily return > 10% is unusual for blue chips)
var outliers = silverDt.AsEnumerable()
.Where(r => Math.Abs(Convert.ToDouble(r["daily_return"])) > 0.10)
.ToList();
if (outliers.Count > 0)
{
Console.WriteLine($" {outliers.Count} rows with |daily_return| > 10%:");
var outlierDt = silverDt.Clone();
foreach (var r in outliers) outlierDt.ImportRow(r);
display(outlierDt);
}
else
{
Console.WriteLine(" No outliers detected");
}
silverDqDQ PASS: silver: 2530 rows
DQ PASS: silver: no null keys in [symbol, date, daily_return]
DQ PASS: silver: no duplicates
DQ PASS: silver: 'daily_return' within range
DQ PASS: silver: 'intraday_range' within range
DQ PASS: silver: latest date 2026-03-27 (3d ago)
DQ PASS: silver: 2530 rows
Outlier checks (warnings only):
3 rows with |daily_return| > 10%:
| symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits | daily_return | intraday_range | sma_20 | batch_id |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| BAS.DE | 05-Mar-25 0:00:00 | 50.099998474121094 | 53.68000030517578 | 50.04999923706055 | 53.65999984741211 | 50.94013214111328 | 9216640 | 0 | 0 | 0.107077 | 0.067648 | 49.203 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| SAP.DE | 23-Apr-25 0:00:00 | 242.39999389648438 | 244.1999969482422 | 236.1999969482422 | 241.6999969482422 | 239.53497314453125 | 3410054 | 0 | 0 | 0.106178 | 0.033099 | 235.6525 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| SAP.DE | 29-Jan-26 0:00:00 | 179 | 180.16000366210938 | 162.1199951171875 | 164.6199951171875 | 164.6199951171875 | 15846791 | 0 | 0 | -0.160702 | 0.109586 | 200.193 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| check | status |
|---|---|
| silver: 2530 rows | PASS |
| silver: no null keys in [symbol, date, daily_return] | PASS |
| silver: no duplicates | PASS |
| silver: 'daily_return' within range | PASS |
| silver: 'intraday_range' within range | PASS |
| silver: latest date 2026-03-27 (3d ago) | PASS |
| silver: 2530 rows | PASS |
8. Gold Layer — Aggregations & Mart Tables
Gold produces consumption-ready data products from Silver. Two aggregations, both pure functions: DailySummary (cross-sectional: all symbols for each date) and SymbolProfile (longitudinal: full history for each symbol). Gold is always a full rebuild — truncate and recompute from Silver on every run. This is simpler than incremental and guarantees consistency. Acceptable because Gold tables are small (~50 symbols x 1 row + ~500 daily rows). Both are validated through the typed contracts before persistence.
Without Gold Validation
An aggregation bug produces max_drawdown = 0.15 (positive). This is mathematically impossible — drawdown is always negative. Without the le=0 constraint, the bad value reaches the dashboard. A portfolio manager sees “positive drawdown” and makes decisions on nonsensical data.
With Gold Validation
The gold contract enforces mathematical invariants on every aggregated row before persistence —
max_drawdown <= 0,avg_volume >= 0,sharpe_ratiofinite. A bad aggregation is rejected at the gold boundary, not discovered by a portfolio manager three days later.
Aggregation functions
Pure LINQ functions building DailySummary (cross-sectional) and SymbolProfile (longitudinal) from Silver data.
LINQ — build daily cross-sectional summary with GroupBy()
Aggregation: Daily Summary
Groups all symbols by date: mean/max/min return, total volume, avg intraday range. One row per trading day.
DataTable BuildDailySummary(DataTable silverDt, string batchId)
{
var dt = new DataTable();
dt.Columns.Add("date", typeof(DateTime));
dt.Columns.Add("symbols_traded", typeof(int));
dt.Columns.Add("avg_return", typeof(double));
dt.Columns.Add("max_return", typeof(double));
dt.Columns.Add("min_return", typeof(double));
dt.Columns.Add("total_volume", typeof(long));
dt.Columns.Add("avg_intraday_pct", typeof(double));
dt.Columns.Add("batch_id", typeof(string));
var groups = silverDt.AsEnumerable()
.GroupBy(r => r.Field<DateTime>("date"))
.OrderBy(g => g.Key);
foreach (var group in groups)
{
var returns = group.Select(r => Convert.ToDouble(r["daily_return"])).ToList();
var volumes = group.Select(r => Convert.ToInt64(r["volume"])).ToList();
var intraday = group.Select(r => Convert.ToDouble(r["intraday_range"])).ToList();
dt.Rows.Add(
group.Key,
group.Select(r => r.Field<string>("symbol")).Distinct().Count(),
Math.Round(returns.Average(), 6),
returns.Max(),
returns.Min(),
volumes.Sum(),
Math.Round(intraday.Average(), 6),
batchId
);
}
return dt;
}
var dailySummaryDt = BuildDailySummary(silverDt, batchId);
Console.WriteLine($"Daily summary: {dailySummaryDt.Rows.Count} trading days");
var previewDaily = dailySummaryDt.Clone();
foreach (DataRow r in dailySummaryDt.AsEnumerable().Take(5)) previewDaily.ImportRow(r);
previewDailyDaily summary: 506 trading days
| date | symbols_traded | avg_return | max_return | min_return | total_volume | avg_intraday_pct | batch_id |
|---|---|---|---|---|---|---|---|
| 28-Mar-24 0:00:00 | 5 | 0 | 0 | 0 | 14115241 | 0.011246 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| 02-Apr-24 0:00:00 | 5 | -0.006261 | 0.016815 | -0.018841 | 15460060 | 0.02085 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| 03-Apr-24 0:00:00 | 5 | 0.004862 | 0.01282 | -0.002239 | 12344475 | 0.014617 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| 04-Apr-24 0:00:00 | 5 | -0.000631 | 0.007522 | -0.007289 | 10238748 | 0.01083 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| 05-Apr-24 0:00:00 | 5 | -0.014092 | -0.00337 | -0.02146 | 16364036 | 0.017791 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
LINQ — build per-symbol profile with cumulative max drawdown
Aggregation: Symbol Risk Profile
Per-symbol over full history: avg return, volatility (daily sigma), max drawdown (peak-to-trough), total dividends.
DataTable BuildSymbolProfile(DataTable silverDt, string batchId)
{
var dt = new DataTable();
dt.Columns.Add("symbol", typeof(string));
dt.Columns.Add("total_trading_days", typeof(int));
dt.Columns.Add("avg_daily_return", typeof(double));
dt.Columns.Add("volatility", typeof(double));
dt.Columns.Add("max_drawdown", typeof(double));
dt.Columns.Add("avg_volume", typeof(double));
dt.Columns.Add("total_dividends", typeof(double));
dt.Columns.Add("first_date", typeof(DateTime));
dt.Columns.Add("last_date", typeof(DateTime));
dt.Columns.Add("batch_id", typeof(string));
var groups = silverDt.AsEnumerable()
.GroupBy(r => r.Field<string>("symbol"))
.OrderBy(g => g.Key);
foreach (var group in groups)
{
var rows = group.OrderBy(r => r.Field<DateTime>("date")).ToList();
var returns = rows.Select(r => Convert.ToDouble(r["daily_return"])).ToList();
var volumes = rows.Select(r => Convert.ToInt64(r["volume"])).ToList();
var dividends = rows.Select(r => Convert.ToDouble(r["dividends"])).ToList();
var closes = rows.Select(r => Convert.ToDouble(r["close"])).ToList();
var dates = rows.Select(r => r.Field<DateTime>("date")).ToList();
// Max drawdown: peak-to-trough decline using cumulative max of close
double peak = closes[0];
double maxDd = 0;
foreach (var c in closes)
{
if (c > peak) peak = c;
double dd = (c - peak) / peak;
if (dd < maxDd) maxDd = dd;
}
double avgRet = returns.Average();
double vol = Math.Sqrt(returns.Select(r => Math.Pow(r - avgRet, 2)).Average());
dt.Rows.Add(
group.Key,
rows.Count,
Math.Round(avgRet, 6),
Math.Round(vol, 6),
Math.Round(maxDd, 6),
Math.Round(volumes.Average(), 2),
Math.Round(dividends.Sum(), 4),
dates.First(),
dates.Last(),
batchId
);
}
return dt;
}
var symbolProfileDt = BuildSymbolProfile(silverDt, batchId);
Console.WriteLine($"Symbol profiles: {symbolProfileDt.Rows.Count} symbols");
symbolProfileDtSymbol profiles: 5 symbols
| symbol | total_trading_days | avg_daily_return | volatility | max_drawdown | avg_volume | total_dividends | first_date | last_date | batch_id |
|---|---|---|---|---|---|---|---|---|---|
| ALV.DE | 506 | 0.000532 | 0.011839 | -0.123504 | 631486.14 | 29.2 | 28-Mar-24 0:00:00 | 27-Mar-26 0:00:00 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| BAS.DE | 506 | 0.000121 | 0.017491 | -0.276766 | 2518782.14 | 5.65 | 28-Mar-24 0:00:00 | 27-Mar-26 0:00:00 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| DTE.DE | 506 | 0.000765 | 0.01325 | -0.266109 | 6531019.06 | 1.67 | 28-Mar-24 0:00:00 | 27-Mar-26 0:00:00 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| SAP.DE | 506 | -0.000285 | 0.0189 | -0.491402 | 1676455.19 | 4.55 | 28-Mar-24 0:00:00 | 27-Mar-26 0:00:00 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| SIE.DE | 506 | 0.000475 | 0.019204 | -0.273251 | 1155097.07 | 10.55 | 28-Mar-24 0:00:00 | 27-Mar-26 0:00:00 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
FluentValidation — validate Gold daily summary with row-level check
// Validates each row to catch aggregation errors before persistence
(DataTable Valid, int Rejected) ValidateGoldDaily(DataTable dt)
{
var valid = dt.Clone();
int rejected = 0;
foreach (DataRow row in dt.Rows)
{
try
{
var record = new DailySummary(
(DateTime)row["date"],
Convert.ToInt32(row["symbols_traded"]),
Convert.ToDouble(row["avg_return"]),
Convert.ToDouble(row["max_return"]),
Convert.ToDouble(row["min_return"]),
Convert.ToInt64(row["total_volume"]),
Convert.ToDouble(row["avg_intraday_pct"]),
row["batch_id"].ToString());
valid.ImportRow(row);
}
catch
{
rejected++;
}
}
return (valid, rejected);
}
var (validDaily, rejDaily) = ValidateGoldDaily(dailySummaryDt);
Console.WriteLine($"Daily summary validation: {validDaily.Rows.Count} valid, {rejDaily} rejected");Daily summary validation: 506 valid, 0 rejected
FluentValidation — validate Gold symbol profiles with row-level check
// Validates each profile to catch calculation errors
(DataTable Valid, int Rejected) ValidateGoldProfiles(DataTable dt)
{
var valid = dt.Clone();
int rejected = 0;
foreach (DataRow row in dt.Rows)
{
try
{
var record = new SymbolProfile(
row["symbol"].ToString(),
Convert.ToInt32(row["total_trading_days"]),
Convert.ToDouble(row["avg_daily_return"]),
Convert.ToDouble(row["volatility"]),
Convert.ToDouble(row["max_drawdown"]),
Convert.ToDouble(row["avg_volume"]),
Convert.ToDouble(row["total_dividends"]),
(DateTime)row["first_date"],
(DateTime)row["last_date"],
row["batch_id"].ToString());
valid.ImportRow(row);
}
catch (Exception ex)
{
rejected++;
Console.WriteLine($" Rejected {row["symbol"]}: {ex.Message}");
}
}
return (valid, rejected);
}
var (validProfiles, rejProfiles) = ValidateGoldProfiles(symbolProfileDt);
Console.WriteLine($"Symbol profile validation: {validProfiles.Rows.Count} valid, {rejProfiles} rejected");Symbol profile validation: 5 valid, 0 rejected
Persistence
Validate Gold rows through typed contracts, then truncate and reload both mart tables.
SQL Server — define Gold persistence function with TRUNCATE + WriteToCsv()
Gold: Truncate and Rebuild
Gold is always a full rebuild from Silver — not incremental. TRUNCATE both Gold tables, then INSERT new aggregations.
StageLineage PersistGold(DataTable dailyDt, DataTable profileDt, string batchId)
{
int totalInput = dailyDt.Rows.Count + profileDt.Rows.Count;
var stageCtx = StartStage(batchId, "gold", totalInput);
// Truncate and insert
if (dailyDt.Rows.Count > 0)
{
sqlConn.Execute("TRUNCATE TABLE gold_daily_summary");
WriteToCsv(dailyDt, "gold_daily_summary", false);
}
if (profileDt.Rows.Count > 0)
{
sqlConn.Execute("TRUNCATE TABLE gold_symbol_profile");
WriteToCsv(profileDt, "gold_symbol_profile", false);
}
// Combined DataTable for hash
var combined = dailyDt.Copy();
foreach (DataRow r in profileDt.Rows)
{
var newRow = combined.NewRow();
combined.Rows.Add(newRow);
}
var lineage = EndStage(stageCtx, combined, 0);
PersistLineage(lineage);
return lineage;
}
SQL Server — persist Gold marts with TRUNCATE + WriteToCsv()
Gold Execution with Context
Persists Gold marts, checks for days with missing symbols, records warnings in .
var goldLineage = PersistGold(validDaily, validProfiles, batchId);
var missingSymbols = validDaily.AsEnumerable()
.Where(r => Convert.ToInt32(r["symbols_traded"]) < SYMBOLS.Length)
.ToList();
if (missingSymbols.Count > 0)
{
foreach (var row in missingSymbols.Take(5))
{
goldStageCtx.AddWarning(
$"gold: {((DateTime)row["date"]):yyyy-MM-dd} only {row["symbols_traded"]}/{SYMBOLS.Length} symbols");
}
}
PersistContext(goldStageCtx);
Console.WriteLine($"Gold persisted: hash={goldLineage.OutputHash}");Gold persisted: hash=a87ddaf98043eb7c
SQL Server — display Gold daily summary with QueryToTable()
// Show the most recent trading days with cross-sectional metrics
QueryToTable("SELECT TOP 5 * FROM gold_daily_summary ORDER BY date DESC")| id | date | symbols_traded | avg_return | max_return | min_return | total_volume | avg_intraday_pct | batch_id |
|---|---|---|---|---|---|---|---|---|
| 506 | 27-Mar-26 0:00:00 | 5 | -0.003768 | 0.026803 | -0.023123 | 16229634 | 0.024848 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| 505 | 26-Mar-26 0:00:00 | 5 | -0.006063 | 0.014394 | -0.015385 | 15722294 | 0.01961 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| 504 | 25-Mar-26 0:00:00 | 5 | 0.008021 | 0.023951 | -0.004877 | 13536325 | 0.019512 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| 503 | 24-Mar-26 0:00:00 | 5 | 0.003854 | 0.0418 | -0.040556 | 14990944 | 0.028663 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
| 502 | 23-Mar-26 0:00:00 | 5 | 0.012035 | 0.037055 | -0.00253 | 21900746 | 0.071276 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
SQL Server — display Gold symbol profiles with QueryToTable()
// Final per-symbol summary statistics
QueryToTable(@"SELECT symbol, total_trading_days, avg_daily_return,
volatility, max_drawdown, avg_volume, total_dividends
FROM gold_symbol_profile ORDER BY symbol")| symbol | total_trading_days | avg_daily_return | volatility | max_drawdown | avg_volume | total_dividends |
|---|---|---|---|---|---|---|
| ALV.DE | 506 | 0.000532 | 0.011839 | -0.123504 | 631486.14 | 29.2 |
| BAS.DE | 506 | 0.000121 | 0.017491 | -0.276766 | 2518782.14 | 5.65 |
| DTE.DE | 506 | 0.000765 | 0.01325 | -0.266109 | 6531019.06 | 1.67 |
| SAP.DE | 506 | -0.000285 | 0.0189 | -0.491402 | 1676455.19 | 4.55 |
| SIE.DE | 506 | 0.000475 | 0.019204 | -0.273251 | 1155097.07 | 10.55 |
9. Parquet Export — Pre-Materialized Data Products
The serving layer reads Parquet files, not SQL Server. This is the pre-materialized views pattern — the pipeline produces finished data products as files, the API is a thin reader with zero database dependency at serving time. Deployment is a file copy, not a migration. Cache invalidation = re-run the pipeline. Data contracts (JSON Schema with column semantics) are exported alongside the Parquet files, making each data product self-describing.
Without Pre-Materialization
The API queries SQL Server on every request. A slow query blocks the response. A database restart takes the API down. With Parquet files, the API has no database dependency — it reads a file that the pipeline pre-computed. The API can serve data even if SQL Server is down.
With Pre-Materialization
The pipeline exports finished Parquet files as part of each run. The ASP.NET API reads those files directly — zero SQL dependency at serving time. A SQL Server restart has no impact on API availability. Deployment is a file copy, not a migration.
Export operations
Write Gold and Silver data products to Parquet, record the export lineage stage, and verify file integrity.
ParquetSharp — export daily summary to Parquet with WriteDataTableToParquet()
Parquet: Pre-Materialized View
API reads this file directly. Parquet preserves types without CSV parsing overhead.
using ParquetSharp;
void WriteDataTableToParquet(DataTable dt, string path)
{
// Detect which columns have DBNull values (nullable)
var hasNulls = new HashSet<string>();
foreach (DataColumn col in dt.Columns)
if (dt.AsEnumerable().Any(r => r[col] is DBNull))
hasNulls.Add(col.ColumnName);
var columns = new List<Column>();
foreach (DataColumn col in dt.Columns)
{
bool nullable = hasNulls.Contains(col.ColumnName);
if (col.DataType == typeof(DateTime))
columns.Add(nullable ? new Column<DateTime?>(col.ColumnName) : new Column<DateTime>(col.ColumnName));
else if (col.DataType == typeof(double) || col.DataType == typeof(object))
columns.Add(nullable ? new Column<double?>(col.ColumnName) : new Column<double>(col.ColumnName));
else if (col.DataType == typeof(long) || col.DataType == typeof(int))
columns.Add(nullable ? new Column<long?>(col.ColumnName) : new Column<long>(col.ColumnName));
else
columns.Add(new Column<string>(col.ColumnName));
}
using var file = new ParquetFileWriter(path, columns.ToArray());
using var rowGroup = file.AppendRowGroup();
foreach (DataColumn col in dt.Columns)
{
bool nullable = hasNulls.Contains(col.ColumnName);
if (col.DataType == typeof(DateTime) && nullable)
{
using var w = rowGroup.NextColumn().LogicalWriter<DateTime?>();
w.WriteBatch(dt.AsEnumerable().Select(r => r[col] is DBNull ? (DateTime?)null : (DateTime)r[col]).ToArray());
}
else if (col.DataType == typeof(DateTime))
{
using var w = rowGroup.NextColumn().LogicalWriter<DateTime>();
w.WriteBatch(dt.AsEnumerable().Select(r => (DateTime)r[col]).ToArray());
}
else if ((col.DataType == typeof(double) || col.DataType == typeof(object)) && nullable)
{
using var w = rowGroup.NextColumn().LogicalWriter<double?>();
w.WriteBatch(dt.AsEnumerable().Select(r => r[col] is DBNull ? (double?)null : Convert.ToDouble(r[col])).ToArray());
}
else if (col.DataType == typeof(double) || col.DataType == typeof(object))
{
using var w = rowGroup.NextColumn().LogicalWriter<double>();
w.WriteBatch(dt.AsEnumerable().Select(r => Convert.ToDouble(r[col])).ToArray());
}
else if ((col.DataType == typeof(long) || col.DataType == typeof(int)) && nullable)
{
using var w = rowGroup.NextColumn().LogicalWriter<long?>();
w.WriteBatch(dt.AsEnumerable().Select(r => r[col] is DBNull ? (long?)null : Convert.ToInt64(r[col])).ToArray());
}
else if (col.DataType == typeof(long) || col.DataType == typeof(int))
{
using var w = rowGroup.NextColumn().LogicalWriter<long>();
w.WriteBatch(dt.AsEnumerable().Select(r => Convert.ToInt64(r[col])).ToArray());
}
else
{
using var w = rowGroup.NextColumn().LogicalWriter<string>();
w.WriteBatch(dt.AsEnumerable().Select(r => r[col] is DBNull ? "" : r[col].ToString()).ToArray());
}
}
}
var dailyPath = Path.Combine(EXPORT_DIR, "gold_daily_summary.parquet");
WriteDataTableToParquet(validDaily, dailyPath);
var sizeKb = new FileInfo(dailyPath).Length / 1024.0;
Console.WriteLine($"Exported: {Path.GetFileName(dailyPath)} ({sizeKb:F1} KB, {validDaily.Rows.Count} rows)");Exported: gold_daily_summary.parquet (26.8 KB, 506 rows)
ParquetSharp — export symbol profiles to Parquet with WriteDataTableToParquet()
// ── Pre-materialized view: per-symbol summary for the comparison dashboard ──
var profilePath = Path.Combine(EXPORT_DIR, "gold_symbol_profile.parquet");
WriteDataTableToParquet(validProfiles, profilePath);
var sizeKb = new FileInfo(profilePath).Length / 1024.0;
Console.WriteLine($"Exported: {Path.GetFileName(profilePath)} ({sizeKb:F1} KB, {validProfiles.Rows.Count} rows)");Exported: gold_symbol_profile.parquet (2.6 KB, 5 rows)
ParquetSharp — export Silver data to Parquet with WriteDataTableToParquet()
Silver Parquet Export
Full Silver dataset exported for time-series and per-symbol drill-down endpoints.
var silverPath = Path.Combine(EXPORT_DIR, "silver_ohlcv.parquet");
WriteDataTableToParquet(silverDt, silverPath);
var sizeKb = new FileInfo(silverPath).Length / 1024.0;
Console.WriteLine($"Exported: {Path.GetFileName(silverPath)} ({sizeKb:F1} KB, {silverDt.Rows.Count} rows)");Exported: silver_ohlcv.parquet (160.7 KB, 2530 rows)
Lineage — record export stage with EndStage()
// ── Track which files were exported and their sizes ──
var exportCtx = StartStage(batchId, "export", inputRows: validDaily.Rows.Count + validProfiles.Rows.Count + silverDt.Rows.Count);
// Combined export DataTable for hash (all exported data)
var exportCombined = new DataTable();
exportCombined.Columns.Add("data", typeof(string));
foreach (DataTable dt in new[] { validDaily, validProfiles, silverDt })
foreach (DataRow row in dt.Rows)
exportCombined.Rows.Add(string.Join("|", row.ItemArray.Select(v => v?.ToString() ?? "")));
var exportLineage = EndStage(exportCtx, exportCombined, 0);
PersistLineage(exportLineage);
Console.WriteLine($"Export lineage recorded: {exportLineage.OutputRows} total rows, hash={exportLineage.OutputHash}");Export lineage recorded: 3041 total rows, hash=4915a3cad5280dd8
ParquetSharp — verify exported Parquet files with ParquetFileReader
// ── Round-trip test: verify Parquet files exist and report sizes ──
foreach (var name in new[] { "gold_daily_summary", "gold_symbol_profile", "silver_ohlcv" })
{
var path = Path.Combine(EXPORT_DIR, $"{name}.parquet");
using var reader = new ParquetFileReader(path);
var meta = reader.FileMetaData;
Console.WriteLine($" {name}: {meta.NumRows} rows, {meta.NumColumns} cols");
}gold_daily_summary: 506 rows, 8 cols
gold_symbol_profile: 5 rows, 10 cols
silver_ohlcv: 2530 rows, 14 cols
C# — export data contracts as JSON Schema with JsonSerializer
// ── Export machine-readable contracts for every pipeline boundary ──
var contractPaths = ExportDataContracts(EXPORT_DIR);
foreach (var p in contractPaths)
Console.WriteLine($" {Path.GetFileName(p)}: {new FileInfo(p).Length:N0} bytes");Contract exported: bronze_ohlcv_contract.json
Contract exported: silver_ohlcv_contract.json
Contract exported: gold_daily_summary_contract.json
Contract exported: gold_symbol_profile_contract.json
bronze_ohlcv_contract.json: 4'279 bytes
silver_ohlcv_contract.json: 5'861 bytes
gold_daily_summary_contract.json: 3'080 bytes
gold_symbol_profile_contract.json: 4'074 bytes
10. Lineage Review — Pipeline Execution Audit
After all stages complete, the full execution trail is available for review across five artifacts: stage lineage (timing, row counts, hashes), RunContext JSON (execution envelope with business and temporal context), context log (warnings per stage in SQL Server), quarantine (every rejected row with its error), and data contracts (column-level semantics as JSON Schema). Together these answer any question about what the pipeline did, why it did it, what it knew, and what it produced.
Pipeline audit
Finalize and persist the RunContext, then query lineage, quarantine, and context log tables for the current batch.
C# — build and save run context with RunContext
Finalize RunContext
Combines stage lineage, business context, temporal context, and data warnings into the final execution record. Persisted as JSON.
var runContext = new RunContext {
BatchId = batchId,
StartedAt = bronzeLineage.StartedAt,
CompletedAt = exportLineage.CompletedAt,
Symbols = SYMBOLS,
DateRange = new[] { START_DATE, END_DATE },
Stages = new List<StageLineage> { bronzeLineage, silverLineage, goldLineage, exportLineage },
Status = "completed",
BizContext = bizCtx,
TempContext = tempCtx,
DataWarnings = goldStageCtx.DataWarnings,
ContractVersion = "1.0",
};
string SaveRunContext(RunContext ctx)
{
var path = Path.Combine(LINEAGE_DIR, $"run_{ctx.BatchId[..8]}.json");
File.WriteAllText(path, JsonSerializer.Serialize(ctx, new JsonSerializerOptions { WriteIndented = true }));
return path;
}
var ctxPath = SaveRunContext(runContext);
var totalMs = runContext.Stages.Sum(s => s.DurationMs);
Console.WriteLine($"RunContext saved: {Path.GetFileName(ctxPath)}");
Console.WriteLine($"Batch: {batchId[..8]}... | Warnings: {runContext.DataWarnings.Count}");
Console.WriteLine($"Processing time: {totalMs:F0}ms");RunContext saved: run_d99b77ff.json
Batch: d99b77ff... | Warnings: 1
Processing time: 7219ms
C# — display lineage summary as DataTable
// ── Shows all stages with timing, row counts, and hashes ──
var lineageDt = new DataTable();
lineageDt.Columns.Add("stage", typeof(string));
lineageDt.Columns.Add("input_rows", typeof(int));
lineageDt.Columns.Add("output_rows", typeof(int));
lineageDt.Columns.Add("rejected", typeof(int));
lineageDt.Columns.Add("duration_ms", typeof(double));
lineageDt.Columns.Add("output_hash", typeof(string));
foreach (var s in runContext.Stages)
lineageDt.Rows.Add(s.Stage, s.InputRows, s.OutputRows, s.RowsRejected, Math.Round(s.DurationMs, 1), s.OutputHash);
lineageDt| stage | input_rows | output_rows | rejected | duration_ms | output_hash |
|---|---|---|---|---|---|
| bronze | 5 | 5 | 0 | 561 | 959fbdf2bc0d14a5 |
| silver | 2530 | 2530 | 0 | 5776.9 | 76d1db80852e66dc |
| gold | 511 | 511 | 0 | 877.2 | a87ddaf98043eb7c |
| export | 3041 | 3041 | 0 | 4.3 | 4915a3cad5280dd8 |
JSON — read back persisted run context with JsonSerializer.Deserialize()
Verify RunContext JSON
Check the JSON file is complete and parseable. Shows business_context and temporal_context.
var ctxJson = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(ctxPath));
// Display key fields without the bulky stages array
var displayCtx = new Dictionary<string, object>();
foreach (var prop in ctxJson.EnumerateObject())
{
if (prop.Name == "Stages")
displayCtx[prop.Name] = $"[{prop.Value.GetArrayLength()} stage records]";
else if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array)
displayCtx[prop.Name] = JsonSerializer.Deserialize<object>(prop.Value.GetRawText());
else
displayCtx[prop.Name] = prop.Value.ToString();
}
Console.WriteLine(JsonSerializer.Serialize(displayCtx, new JsonSerializerOptions { WriteIndented = true }));{
"BatchId": "d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f",
"StartedAt": "2026-03-29T23:24:03.1918047Z",
"CompletedAt": "2026-03-29T23:24:12.045971Z",
"Symbols": [
"SAP.DE",
"SIE.DE",
"ALV.DE",
"DTE.DE",
"BAS.DE"
],
"DateRange": [
"2024-03-30",
"2026-03-30"
],
"PolarsVersion": "Polars.NET",
"Stages": "[4 stage records]",
"Status": "completed",
"BizContext": {
"Trigger": "scheduled",
"Reason": null,
"BusinessDate": "2026-03-29T00:00:00\u002B01:00",
"IsCorrection": false,
"AffectedSymbols": null
},
"TempContext": {
"AsOfDate": "2026-03-29T00:00:00\u002B01:00",
"ReportingPeriodStart": "2024-03-30T00:00:00",
"ReportingPeriodEnd": "2026-03-30T00:00:00",
"KnowledgeDate": "2026-03-29T23:24:03.1903432Z",
"Timezone": "CET",
"IsBackfill": false
},
"DataWarnings": [
"sma_20: 95 NULL values (first 19 rows per symbol)"
],
"ContractVersion": "1.0"
}
SQL Server — query lineage table with QueryToTable()
// ── Verify lineage records were persisted to SQL Server ──
var lineageQuery = QueryToTable(
Console.WriteLine($"SELECT stage, input_rows, output_rows, rows_rejected, output_hash FROM lineage_stages WHERE batch_id = '{batchId}'");
);
lineageQuery| stage | input_rows | output_rows | rows_rejected | output_hash |
|---|---|---|---|---|
| bronze | 5 | 5 | 0 | 959fbdf2bc0d14a5 |
| silver | 2530 | 2530 | 0 | 76d1db80852e66dc |
| gold | 511 | 511 | 0 | a87ddaf98043eb7c |
| export | 3041 | 3041 | 0 | 4915a3cad5280dd8 |
SQL Server — review quarantined rows with QueryToTable()
Review Quarantined Rows
Shows rejected rows with error messages for investigation.
var quarantineDt = QueryToTable(
Console.WriteLine($"SELECT stage, symbol, date, error_message, quarantined_at FROM quarantine WHERE batch_id = '{batchId}' ORDER BY quarantined_at");
);
if (quarantineDt.Rows.Count > 0)
{
Console.WriteLine($"WARNING: Quarantined rows: {quarantineDt.Rows.Count}");
display(quarantineDt);
}
else
{
}No quarantined rows — all data passed validation
SQL Server — query context log for this batch with QueryToTable()
Context Audit per Stage
Lineage = what happened. Context = what the pipeline knew at each stage.
var contextDt = QueryToTable(
Console.WriteLine($"SELECT stage, business_date, trigger_type, schema_version, data_warnings FROM context_log WHERE batch_id = '{batchId}' ORDER BY created_at");
);
contextDt| stage | business_date | trigger_type | schema_version | data_warnings |
|---|---|---|---|---|
| bronze | 29-Mar-26 0:00:00 | scheduled | 1.0 | |
| silver | 29-Mar-26 0:00:00 | scheduled | 1.0 | ["sma_20: 95 NULL values (first 19 rows per symbol)"] |
| gold | 29-Mar-26 0:00:00 | scheduled | 1.0 | ["sma_20: 95 NULL values (first 19 rows per symbol)"] |
C# — display accumulated data warnings with Console.WriteLine()
// ── Show all data warnings accumulated across stages ──
if (goldStageCtx != null && goldStageCtx.DataWarnings.Count > 0)
{
Console.WriteLine($"Data Warnings ({goldStageCtx.DataWarnings.Count} total):");
foreach (var w in goldStageCtx.DataWarnings)
Console.WriteLine($" {w}");
}
else
{
}Data Warnings (1 total):
sma_20: 95 NULL values (first 19 rows per symbol)
JSON — inspect exported data contract with JsonSerializer.Deserialize()
// ── Inspect the Silver contract — shows structural schema + column semantics ──
var contractPath = Path.Combine(EXPORT_DIR, "contracts", "silver_ohlcv_contract.json");
if (File.Exists(contractPath))
{
var contract = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(contractPath));
Console.WriteLine($"Contract: {Path.GetFileName(contractPath)}");
Console.WriteLine($" Version: {(contract.TryGetProperty("x-contract-version", out var v) ? v.ToString() : "N/A")}");
Console.WriteLine($" Generated: {(contract.TryGetProperty("x-generated-at", out var g) ? g.ToString() : "N/A")}");
Console.WriteLine($" Fields: {(contract.TryGetProperty("properties", out var props) ? props.EnumerateObject().Count() : 0)}");
if (contract.TryGetProperty("x-column-context", out var colCtx))
{
Console.WriteLine($" Column contexts: {colCtx.GetArrayLength()}");
foreach (var col in colCtx.EnumerateArray())
{
if (col.TryGetProperty("is_derived", out var isDerived) && isDerived.GetBoolean())
{
Console.WriteLine($" {col.GetProperty("name")}:");
Console.WriteLine($" {col.GetProperty("description")}");
Console.WriteLine($" Computation: {col.GetProperty("computation")}");
Console.WriteLine($" Sources: {col.GetProperty("source_columns")}");
Console.WriteLine($" Null means: {col.GetProperty("null_semantics")}");
}
}
}
}Contract: silver_ohlcv_contract.json
Version: 1.0
Generated: 2026-03-29T23:24:12.1161340Z
Fields: 13
Column contexts: 13
daily_return:
Close-to-close return
Computation: pct_change(close).over(symbol)
Sources: [
"bronze.close"
]
Null means: first_row_in_series
intraday_range:
(high-low)/close
Computation: (high - low) / close
Sources: [
"bronze.high",
"bronze.low",
"bronze.close"
]
Null means: not_applicable
sma_20:
20-day moving average of close
Computation: close.rolling_mean(20).over(symbol)
Sources: [
"bronze.close"
]
Null means: insufficient_data
11. HttpListener Serving Layer — Pre-Materialized JSON API
HttpListener serves the Gold data products by reading pre-materialized JSON files. No database connection at runtime — the API reads files that the pipeline produced. Four endpoints: health (operational monitoring), daily-summary (market overview), symbol-profile (stock comparison), and lineage (pipeline execution audit).
API models
Typed record contracts that define the JSON shape returned by each HttpListener endpoint.
C# — define daily summary API response record
// ── Response schema for the /daily-summary endpoint ──
public record DailySummaryResponse(
DateTime Date,
int SymbolsTraded,
double AvgReturn,
double MaxReturn,
double MinReturn,
long TotalVolume,
double AvgIntradayPct
);
C# — define symbol profile API response record
// ── Response schema for the /symbol-profile endpoint ──
public record SymbolProfileResponse(
string Symbol,
int TotalTradingDays,
double AvgDailyReturn,
double Volatility,
double MaxDrawdown,
double AvgVolume,
double TotalDividends,
DateTime FirstDate,
DateTime LastDate
);
C# — define timeseries row API response record
// ── Response schema for the /symbol/{symbol}/timeseries endpoint ──
public record TimeSeriesRow(
DateTime Date,
double Open,
double High,
double Low,
double Close,
long Volume,
double DailyReturn,
double IntradayRange,
double? Sma20
);
Endpoints
HttpListener application instance and route handlers: health, daily-summary, symbol-profile, timeseries, and lineage.
HttpListener — create listener instance with HttpListener()
// ── Serialize pipeline outputs to JSON for HttpListener endpoints ──
var dailyJson = JsonSerializer.Serialize(validDaily.AsEnumerable().Select(r => new {
date = r.Field<DateTime>("date").ToString("yyyy-MM-dd"),
symbols_traded = Convert.ToInt32(r["symbols_traded"]),
avg_return = Convert.ToDouble(r["avg_return"]),
max_return = Convert.ToDouble(r["max_return"]),
min_return = Convert.ToDouble(r["min_return"]),
total_volume = Convert.ToInt64(r["total_volume"]),
avg_intraday_pct = Convert.ToDouble(r["avg_intraday_pct"]),
}).ToArray());
var profilesJson = JsonSerializer.Serialize(validProfiles.AsEnumerable().Select(r => new {
symbol = r["symbol"]?.ToString() ?? "",
total_trading_days = Convert.ToInt32(r["total_trading_days"]),
avg_daily_return = Convert.ToDouble(r["avg_daily_return"]),
volatility = Convert.ToDouble(r["volatility"]),
max_drawdown = Convert.ToDouble(r["max_drawdown"]),
avg_volume = Convert.ToDouble(r["avg_volume"]),
total_dividends = Convert.ToDouble(r["total_dividends"]),
first_date = r.Field<DateTime>("first_date").ToString("yyyy-MM-dd"),
last_date = r.Field<DateTime>("last_date").ToString("yyyy-MM-dd"),
}).ToArray());
var silverSymbolJson = new Dictionary<string, string>();
foreach (var grp in silverDt.AsEnumerable().GroupBy(r => r["symbol"]?.ToString() ?? ""))
{
silverSymbolJson[grp.Key.ToUpper()] = JsonSerializer.Serialize(grp.Select(r => new {
date = r.Field<DateTime>("date").ToString("yyyy-MM-dd"),
open = Convert.ToDouble(r["open"]),
high = Convert.ToDouble(r["high"]),
low = Convert.ToDouble(r["low"]),
close = Convert.ToDouble(r["close"]),
volume = Convert.ToInt64(r["volume"]),
daily_return = Convert.ToDouble(r["daily_return"]),
intraday_range = Convert.ToDouble(r["intraday_range"]),
sma_20 = r["sma_20"] == DBNull.Value ? (double?)null : Convert.ToDouble(r["sma_20"]),
}).ToArray());
}
JSON data prepared for HttpListener: daily=80'209 chars, profiles=1'089 chars, symbols=5
HttpListener — define health endpoint handler
// ── Healthcheck: verifies Parquet files exist and reports their sizes ──
string HandleHealth()
{
var files = Directory.GetFiles(EXPORT_DIR, "*.parquet")
.ToDictionary(f => Path.GetFileNameWithoutExtension(f), f => new FileInfo(f).Length);
return JsonSerializer.Serialize(new { status = "healthy", files });
}
HttpListener — define daily summary endpoint handler
// ── Returns daily cross-sectional summary from pre-serialized JSON ──
string HandleDailySummary()
{
return dailyJson;
}
HttpListener — define symbol profile endpoint handler
// ── Returns per-symbol summary statistics from pre-serialized JSON ──
string HandleSymbolProfile()
{
return profilesJson;
}
HttpListener — define symbol timeseries endpoint handler
// ── Returns daily OHLCV + enrichment for one symbol from pre-serialized JSON ──
string HandleTimeseries(string symbol)
{
return silverSymbolJson.GetValueOrDefault(symbol.ToUpper(), "[]");
}
HttpListener — define lineage endpoint handler
// ── Returns RunContext JSON for a batch, matched by prefix ──
string HandleLineage(string batchIdPrefix)
{
var files = Directory.GetFiles(LINEAGE_DIR, $"run_{batchIdPrefix}*.json");
return files.Length > 0 ? File.ReadAllText(files[0]) : "{}";
}
HttpListener — start server in background with Thread()
Background HTTP Server
Port 8098 to avoid conflicts. Background thread allows notebook to continue.
int API_PORT = 8098;
var listener = new HttpListener();
listener.Prefixes.Add($"http://localhost:{API_PORT}/");
listener.Start();
async Task HandleRequests()
{
while (listener.IsListening)
{
try
{
var ctx = await listener.GetContextAsync();
var path = ctx.Request.Url.AbsolutePath;
string responseJson = "";
int statusCode = 200;
if (path == "/health")
responseJson = HandleHealth();
else if (path == "/daily-summary")
responseJson = HandleDailySummary();
else if (path == "/symbol-profile")
responseJson = HandleSymbolProfile();
else if (path.StartsWith("/symbol/") && path.EndsWith("/timeseries"))
{
var symbol = path.Split('/')[2];
responseJson = HandleTimeseries(symbol);
}
else if (path.StartsWith("/lineage/"))
{
var prefix = path.Split('/')[2];
responseJson = HandleLineage(prefix);
}
else
statusCode = 404;
var bytes = System.Text.Encoding.UTF8.GetBytes(responseJson);
ctx.Response.StatusCode = statusCode;
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
}
catch (HttpListenerException) { break; }
}
}
var serverThread = new Thread(() => HandleRequests().Wait()) { IsBackground = true };
serverThread.Start();
Thread.Sleep(1000);HttpListener running at http://localhost:8098
Testing
Start the server in a background thread and smoke-test all endpoints with HttpClient.
HttpClient — test health endpoint with GetStringAsync()
// ── Verify the API server is running and Parquet files are accessible ──
var client = new HttpClient();
var resp = await client.GetStringAsync($"http://localhost:{API_PORT}/health");
Console.WriteLine($"Status: 200");
Console.WriteLine(JsonSerializer.Serialize(JsonSerializer.Deserialize<JsonElement>(resp), new JsonSerializerOptions { WriteIndented = true }));Status: 200
{
"status": "healthy",
"files": {
"gold_daily_summary": 27461,
"gold_symbol_profile": 2680,
"silver_ohlcv": 164515
}
}
HttpClient — test daily summary endpoint with GetStringAsync()
// ── Fetch daily cross-sectional summary ──
var resp = await client.GetStringAsync($"http://localhost:{API_PORT}/daily-summary");
var dailyArr = JsonSerializer.Deserialize<JsonElement>(resp);
Console.WriteLine($"Status: 200, rows: {dailyArr.GetArrayLength()}");
// Display as DataTable
var dailyRespDt = new DataTable();
dailyRespDt.Columns.Add("date", typeof(string));
dailyRespDt.Columns.Add("symbols_traded", typeof(int));
dailyRespDt.Columns.Add("avg_return", typeof(double));
dailyRespDt.Columns.Add("total_volume", typeof(long));
foreach (var item in dailyArr.EnumerateArray().Take(5))
dailyRespDt.Rows.Add(
item.GetProperty("date").GetString(),
item.GetProperty("symbols_traded").GetInt32(),
item.GetProperty("avg_return").GetDouble(),
item.GetProperty("total_volume").GetInt64()
);
dailyRespDtStatus: 200, rows: 506
| date | symbols_traded | avg_return | total_volume |
|---|---|---|---|
| 2024-03-28 | 5 | 0 | 14115241 |
| 2024-04-02 | 5 | -0.006261 | 15460060 |
| 2024-04-03 | 5 | 0.004862 | 12344475 |
| 2024-04-04 | 5 | -0.000631 | 10238748 |
| 2024-04-05 | 5 | -0.014092 | 16364036 |
HttpClient — test symbol profile endpoint with GetStringAsync()
// ── Fetch all symbol profiles from the API ──
var resp = await client.GetStringAsync($"http://localhost:{API_PORT}/symbol-profile");
var profileArr = JsonSerializer.Deserialize<JsonElement>(resp);
Console.WriteLine($"Status: 200, profiles: {profileArr.GetArrayLength()}");
var profileRespDt = new DataTable();
profileRespDt.Columns.Add("symbol", typeof(string));
profileRespDt.Columns.Add("total_trading_days", typeof(int));
profileRespDt.Columns.Add("avg_daily_return", typeof(double));
profileRespDt.Columns.Add("volatility", typeof(double));
foreach (var item in profileArr.EnumerateArray())
profileRespDt.Rows.Add(
item.GetProperty("symbol").GetString(),
item.GetProperty("total_trading_days").GetInt32(),
item.GetProperty("avg_daily_return").GetDouble(),
item.GetProperty("volatility").GetDouble()
);
profileRespDtStatus: 200, profiles: 5
| symbol | total_trading_days | avg_daily_return | volatility |
|---|---|---|---|
| ALV.DE | 506 | 0.000532 | 0.011839 |
| BAS.DE | 506 | 0.000121 | 0.017491 |
| DTE.DE | 506 | 0.000765 | 0.01325 |
| SAP.DE | 506 | -0.000285 | 0.0189 |
| SIE.DE | 506 | 0.000475 | 0.019204 |
HttpClient — test symbol timeseries endpoint with GetStringAsync()
// ── Fetch last 10 days of SAP.DE time series data ──
var resp = await client.GetStringAsync($"http://localhost:{API_PORT}/symbol/SAP.DE/timeseries");
var tsArr = JsonSerializer.Deserialize<JsonElement>(resp);
Console.WriteLine($"Status: 200, rows: {tsArr.GetArrayLength()}");
var tsRespDt = new DataTable();
tsRespDt.Columns.Add("date", typeof(string));
tsRespDt.Columns.Add("open", typeof(double));
tsRespDt.Columns.Add("high", typeof(double));
tsRespDt.Columns.Add("low", typeof(double));
tsRespDt.Columns.Add("close", typeof(double));
tsRespDt.Columns.Add("volume", typeof(long));
foreach (var item in tsArr.EnumerateArray().Take(5))
tsRespDt.Rows.Add(
item.GetProperty("date").GetString(),
item.GetProperty("open").GetDouble(),
item.GetProperty("high").GetDouble(),
item.GetProperty("low").GetDouble(),
item.GetProperty("close").GetDouble(),
item.GetProperty("volume").GetInt64()
);
tsRespDtStatus: 200, rows: 506
| date | open | high | low | close | volume |
|---|---|---|---|---|---|
| 2024-03-28 | 181.82000732421875 | 181.86000061035156 | 179.10000610351562 | 180.4600067138672 | 1700825 |
| 2024-04-02 | 181 | 181.9199981689453 | 177.05999755859375 | 177.05999755859375 | 1838833 |
| 2024-04-03 | 178.32000732421875 | 179.52000427246094 | 176.55999755859375 | 178.22000122070312 | 1501774 |
| 2024-04-04 | 177.9199981689453 | 178.4600067138672 | 176.33999633789062 | 178.02000427246094 | 1126985 |
| 2024-04-05 | 175.39999389648438 | 177.9600067138672 | 174.77999877929688 | 177.4199981689453 | 2099406 |
HttpClient — test lineage endpoint with GetStringAsync()
// ── Fetch pipeline execution metadata for this run ──
var resp = await client.GetStringAsync($"http://localhost:{API_PORT}/lineage/{batchId[..8]}");
Console.WriteLine($"Status: 200");
var data = JsonSerializer.Deserialize<JsonElement>(resp);
Console.WriteLine($"Batch: {data.GetProperty("BatchId").GetString()[..8]}... | Status: {data.GetProperty("Status")}");
var stagesDt = new DataTable();
stagesDt.Columns.Add("stage", typeof(string));
stagesDt.Columns.Add("input_rows", typeof(int));
stagesDt.Columns.Add("output_rows", typeof(int));
stagesDt.Columns.Add("rows_rejected", typeof(int));
stagesDt.Columns.Add("output_hash", typeof(string));
foreach (var s in data.GetProperty("Stages").EnumerateArray())
stagesDt.Rows.Add(
s.GetProperty("Stage").GetString(),
s.GetProperty("InputRows").GetInt32(),
s.GetProperty("OutputRows").GetInt32(),
s.GetProperty("RowsRejected").GetInt32(),
s.GetProperty("OutputHash").GetString()
);
stagesDtStatus: 200
Batch: d99b77ff... | Status: completed
| stage | input_rows | output_rows | rows_rejected | output_hash |
|---|---|---|---|---|
| bronze | 5 | 5 | 0 | 959fbdf2bc0d14a5 |
| silver | 2530 | 2530 | 0 | 76d1db80852e66dc |
| gold | 511 | 511 | 0 | a87ddaf98043eb7c |
| export | 3041 | 3041 | 0 | 4915a3cad5280dd8 |
12. Pipeline Visualization — Charts & Metrics
Visual validation of the pipeline output. Each chart answers a specific question about the data: daily return volatility (how noisy is the market?), cumulative investment performance (how would a 1 EUR investment have grown?), risk-return positioning (which stocks offer the best return per unit of risk?), and pipeline execution timing (which stage is the bottleneck?). Charts use dark-theme compatible transparent backgrounds.
Charts
Four Plotly.NET charts covering return time series, cumulative performance, risk-return scatter, and stage timing.
Plotly.NET — plot daily return time series with Chart.Line()
Overlaid line chart showing daily returns across all 5 symbols for the last 3 months.
var threeMonthsAgo = DateTime.Today.AddDays(-90);
var returnTraces = SYMBOLS.Select(symbol =>
{
var symDt = QueryToTable(
Console.WriteLine($"SELECT date, daily_return FROM silver_ohlcv ");
+ $"WHERE symbol = '{symbol}' AND date >= '{threeMonthsAgo:yyyy-MM-dd}' "
+ "ORDER BY date");
var dates = symDt.AsEnumerable().Select(r => r.Field<DateTime>("date")).ToArray();
var returns = symDt.AsEnumerable().Select(r => Convert.ToDouble(r["daily_return"])).ToArray();
return Plotly.NET.CSharp.Chart.Line<DateTime, double, string>(x: dates, y: returns, Name: symbol);
}).ToArray();
Plotly.NET.CSharp.Chart.Combine(returnTraces)
.WithTitle("Daily Returns — Last 3 Months")
.WithXAxisStyle(Title.init("Date"))
.WithYAxisStyle(Title.init("Daily Return"))
.WithSize(750, 450)
.WithLayout(Layout.init<string>(
PaperBGColor: Color.fromString("transparent"),
PlotBGColor: Color.fromString("transparent"),
Font: Font.init(Color: Color.fromHex("#cccccc"))))Plotly.NET — plot cumulative returns comparison with cumulative product
// Shows how a €1 investment in each symbol would have grown
var cumTraces = SYMBOLS.Select(symbol =>
{
var symDt = QueryToTable(
Console.WriteLine($"SELECT date, daily_return FROM silver_ohlcv ");
+ $"WHERE symbol = '{symbol}' ORDER BY date");
var dates = symDt.AsEnumerable().Select(r => r.Field<DateTime>("date")).ToArray();
var returns = symDt.AsEnumerable().Select(r => Convert.ToDouble(r["daily_return"])).ToArray();
var cumRet = new double[returns.Length];
cumRet[0] = 1.0 + returns[0];
for (int i = 1; i < returns.Length; i++)
cumRet[i] = cumRet[i - 1] * (1.0 + returns[i]);
return Plotly.NET.CSharp.Chart.Line<DateTime, double, string>(x: dates, y: cumRet, Name: symbol);
}).ToArray();
Plotly.NET.CSharp.Chart.Combine(cumTraces)
.WithTitle("Cumulative Returns — €1 Investment")
.WithXAxisStyle(Title.init("Date"))
.WithYAxisStyle(Title.init("Growth of €1"))
.WithSize(750, 450)
.WithLayout(Layout.init<string>(
PaperBGColor: Color.fromString("transparent"),
PlotBGColor: Color.fromString("transparent"),
Font: Font.init(Color: Color.fromHex("#cccccc"))))Plotly.NET — plot risk-return scatter with Chart.Point()
// Risk-return visualization using Gold symbol profile data
var profDt = QueryToTable("SELECT symbol, volatility, avg_daily_return FROM gold_symbol_profile");
var scatterSymbols = profDt.AsEnumerable().Select(r => r.Field<string>("symbol")).ToArray();
var vols = profDt.AsEnumerable().Select(r => Convert.ToDouble(r["volatility"]) * 100).ToArray();
var avgRets = profDt.AsEnumerable().Select(r => Convert.ToDouble(r["avg_daily_return"]) * 100).ToArray();
Plotly.NET.CSharp.Chart.Point<double, double, string>(x: vols, y: avgRets, Name: "Symbols",
MultiText: scatterSymbols, TextPosition: StyleParam.TextPosition.TopCenter,
MarkerColor: Color.fromHex("#4285F4"))
.WithMarkerStyle(Size: 12)
.WithTitle("Risk-Return Profile — Volatility vs Avg Daily Return")
.WithXAxisStyle(Title.init("Daily Volatility (%)"))
.WithYAxisStyle(Title.init("Avg Daily Return (%)"))
.WithSize(750, 450)
.WithLayout(Layout.init<string>(
PaperBGColor: Color.fromString("transparent"),
PlotBGColor: Color.fromString("transparent"),
Font: Font.init(Color: Color.fromHex("#cccccc"))))Plotly.NET — plot pipeline stage timing with Chart.Column()
// Shows how long each pipeline stage took in milliseconds
var stageNames = runContext.Stages.Select(s => s.Stage).ToArray();
var durations = runContext.Stages.Select(s => s.DurationMs).ToArray();
Plotly.NET.CSharp.Chart.Column<double, string, string>(values: durations, Keys: stageNames, Name: "Duration")
.WithMarkerStyle(Color: Color.fromColors(new[] {
Color.fromHex("#4285F4"), Color.fromHex("#34A853"),
Color.fromHex("#FBBC04"), Color.fromHex("#EA4335") }))
.WithTitle("Pipeline Stage Duration")
.WithXAxisStyle(Title.init("Stage"))
.WithYAxisStyle(Title.init("Duration (ms)"))
.WithSize(750, 450)
.WithLayout(Layout.init<string>(
PaperBGColor: Color.fromString("transparent"),
PlotBGColor: Color.fromString("transparent"),
Font: Font.init(Color: Color.fromHex("#cccccc"))))13. Audit — Investigating a Disputed Data Point
The audit section demonstrates lineage in action. A stakeholder disputes a specific data point — the pipeline traces it from Gold back to the raw source in seven steps, each independently verifiable: Bronze (raw values as ingested), Silver (computed return verified mathematically), Gold (propagation to aggregation), Lineage (batch metadata with SHA-256 hash), RunContext (execution fingerprint), Landing Zone (raw JSON file on disk), and Live API (corroboration with current source). This is the proof that the architecture’s lineage tracking delivers real forensic capability.
Disputed data point investigation
Seven-step forensic trace from Gold back to raw source: Bronze → Silver → Gold → Lineage → RunContext → Landing Zone → Live API.
SQL Server — query Bronze table for raw ingested values with read_database()
// Step 2: Check Bronze — exact values as persisted, with batch_id and ingestion timestamp
var bronzeAudit = QueryToTable(
@"SELECT symbol, date, [open], high, low, [close],
adj_close, volume, dividends, stock_splits, batch_id, ingested_at
FROM bronze_ohlcv
WHERE symbol = 'SAP.DE' AND date = '2026-01-29'");
bronzeAuditBronze table (raw ingested):
| symbol | date | open | high | low | close | adj_close | volume | dividends | stock_splits | batch_id | ingested_at |
|---|---|---|---|---|---|---|---|---|---|---|---|
| SAP.DE | 29-Jan-26 0:00:00 | 179 | 180.16000366210938 | 162.1199951171875 | 164.6199951171875 | 164.6199951171875 | 15846791 | 0 | 0 | 9c135c08-937d-4413-8bb4-1b66407ed9a5 | 28-Mar-26 23:21:33 |
SQL Server — query Silver table for enriched values with read_database()
// Step 3: Check Silver — verify the daily_return calculation is correct
var silverAudit = QueryToTable(
@"SELECT symbol, date, [close], daily_return, intraday_range, sma_20,
batch_id, processed_at
FROM silver_ohlcv
WHERE symbol = 'SAP.DE' AND date BETWEEN '2026-01-28' AND '2026-01-30'
ORDER BY date");
if (silverAudit.Rows.Count >= 2)
{
var prevClose = Convert.ToDouble(silverAudit.Rows[0]["close"]);
var currClose = Convert.ToDouble(silverAudit.Rows[1]["close"]);
var expectedReturn = (currClose - prevClose) / prevClose;
var actualReturn = Convert.ToDouble(silverAudit.Rows[1]["daily_return"]);
Console.WriteLine($" Previous close (Jan 28): {prevClose:F2}");
Console.WriteLine($" Current close (Jan 29): {currClose:F2}");
Console.WriteLine($" Expected return: ({currClose:F2} - {prevClose:F2}) / {prevClose:F2} = {expectedReturn:F6}");
Console.WriteLine($" Actual return: {actualReturn:F6}");
Console.WriteLine($" Match: {Math.Abs(expectedReturn - actualReturn) < 0.000001}");
}
silverAuditSilver table (enriched, 3-day window):
Previous close (Jan 28): 196.14
Current close (Jan 29): 164.62
Expected return: (164.62 - 196.14) / 196.14 = -0.160702
Actual return: -0.160702
Match: True
| symbol | date | close | daily_return | intraday_range | sma_20 | batch_id | processed_at |
|---|---|---|---|---|---|---|---|
| SAP.DE | 28-Jan-26 0:00:00 | 196.13999938964844 | 0.003068 | 0.020598 | 202.3795 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f | 29-Mar-26 23:24:06 |
| SAP.DE | 29-Jan-26 0:00:00 | 164.6199951171875 | -0.160702 | 0.109586 | 200.193 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f | 29-Mar-26 23:24:06 |
| SAP.DE | 30-Jan-26 0:00:00 | 170.55999755859375 | 0.036083 | 0.038227 | 198.6235 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f | 29-Mar-26 23:24:06 |
SQL Server — query Gold tables for aggregated impact with read_database()
// Step 4: Check Gold — verify the data point propagated to aggregations
var goldDailyAudit = QueryToTable(
@"SELECT date, symbols_traded, avg_return, min_return, max_return, total_volume, batch_id
FROM gold_daily_summary WHERE date = '2026-01-29'");
goldDailyAuditGold daily summary (Jan 29):
The min_return on this day should reflect SAP's -16% drop
| date | symbols_traded | avg_return | min_return | max_return | total_volume | batch_id |
|---|---|---|---|---|---|---|
| 29-Jan-26 0:00:00 | 5 | -0.025652 | -0.160702 | 0.020128 | 25357247 | d99b77ff-42f2-4cde-a7b2-fbfc54ad6e6f |
SQL Server — query lineage table for pipeline run metadata with read_database()
// Step 5: Trace the disputed data point back to its pipeline run
var batchFromBronze = sqlConn.ExecuteScalar<string>(
"SELECT batch_id FROM bronze_ohlcv WHERE symbol = 'SAP.DE' AND date = '2026-01-29'");
Console.WriteLine($"Disputed row: SAP.DE / 2026-01-29");
Console.WriteLine($"Batch ID (from row): {batchFromBronze}");
var lineageAudit = QueryToTable(
$@"SELECT stage, started_at, completed_at, input_rows, output_rows,
rows_rejected, output_hash
FROM lineage_stages WHERE batch_id = '{batchFromBronze}'
ORDER BY started_at");
lineageAuditDisputed row: SAP.DE / 2026-01-29
Batch ID (from row): 9c135c08-937d-4413-8bb4-1b66407ed9a5
Full pipeline execution for this batch:
| stage | started_at | completed_at | input_rows | output_rows | rows_rejected | output_hash |
|---|---|---|---|---|---|---|
| bronze | 28-Mar-26 23:21:34 | 28-Mar-26 23:21:37 | 2530 | 2530 | 0 | 914eccd231d933a2 |
| silver | 28-Mar-26 23:25:09 | 28-Mar-26 23:25:12 | 2530 | 2530 | 0 | d3c2286d9c3a8b8c |
| gold | 28-Mar-26 23:29:18 | 28-Mar-26 23:29:18 | 511 | 511 | 0 | fabdf6a3896591c1 |
| export | 28-Mar-26 23:29:41 | 28-Mar-26 23:29:41 | 3041 | 3041 | 0 | ad1a03cae4b23504 |
JSON — verify RunContext execution metadata with JsonSerializer
// Step 6: Load RunContext — the pipeline's execution fingerprint
var ctxFiles = Directory.GetFiles(LINEAGE_DIR, $"run_{batchFromBronze[..8]}*.json");
if (ctxFiles.Length > 0)
{
var ctxText = File.ReadAllText(ctxFiles[0]);
// Handle BOM if present
if (ctxText[0] == '') ctxText = ctxText[1..];
var ctx = JsonSerializer.Deserialize<JsonElement>(ctxText);
Console.WriteLine($"RunContext: {Path.GetFileName(ctxFiles[0])}");
// Helper: try PascalCase (C#) then snake_case (Python) keys
JsonElement Prop(JsonElement el, string pascal, string snake) =>
el.TryGetProperty(pascal, out var v) ? v :
el.TryGetProperty(snake, out var v2) ? v2 :
throw new Exception($"Key {pascal}/{snake} not found");
Console.WriteLine($" Status: {Prop(ctx, "Status", "status")}");
Console.WriteLine($" Symbols: [{string.Join(", ", Prop(ctx, "Symbols", "symbols").EnumerateArray())}]");
Console.WriteLine($" Date range: [{string.Join(", ", Prop(ctx, "DateRange", "date_range").EnumerateArray())}]");
var stages = Prop(ctx, "Stages", "stages");
var totalRejected = stages.EnumerateArray()
.Sum(s => Prop(s, "RowsRejected", "rows_rejected").GetInt32());
Console.WriteLine($" Rejected: {totalRejected} rows");
Console.WriteLine($" Output hash: {Prop(stages.EnumerateArray().First(), "OutputHash", "output_hash")}");
}
else
{
Console.WriteLine($"No RunContext found for batch {batchFromBronze[..8]}");
}RunContext: run_9c135c08.json
Status: completed
Symbols: [SAP.DE, SIE.DE, ALV.DE, DTE.DE, BAS.DE]
Date range: [2024-03-29, 2026-03-29]
Rejected: 0 rows
Output hash: 914eccd231d933a2
C# — corroborate with landing zone file
// Step 7: Prove the landing zone file exists and contains the record
var landingFile = Path.Combine(LANDING_DIR, "ohlcv_SAP_DE.json");
var rawRecords = JsonSerializer.Deserialize<List<Dictionary<string, JsonElement>>>(
File.ReadAllText(landingFile));
var jan29InFile = rawRecords.Where(r => r["date"].GetString() == "2026-01-29").ToList();
if (jan29InFile.Any())
{
var liveClose29 = jan29InFile[0]["close"].GetDouble();
Console.WriteLine($"Landing file: {Path.GetFileName(landingFile)}");
Console.WriteLine($" Total records: {rawRecords.Count}");
Console.WriteLine($" Jan 29 record found:");
foreach (var kv in jan29InFile[0])
Console.WriteLine($" {kv.Key,-15}: {kv.Value}");
Console.WriteLine($"\n Bronze close: {Convert.ToDouble(bronzeAudit.Rows[0]["close"]):F2}");
Console.WriteLine($" Landing close: {liveClose29}");
Console.WriteLine($" Match: {Math.Abs(liveClose29 - Convert.ToDouble(bronzeAudit.Rows[0]["close"])) < 0.01}");
}
else
{
var dates = rawRecords.Select(r => r["date"].GetString()).OrderBy(d => d).ToList();
Console.WriteLine($"Landing file covers: {dates.First()} to {dates.Last()}");
Console.WriteLine("Jan 29 not in current file (overwritten by incremental fetch)");
Console.WriteLine("In production, landing files are archived and would contain this record");
}Landing file covers: 2026-03-27 to 2026-03-27
Jan 29 not in current file (overwritten by incremental fetch)
In production, landing files are archived and would contain this record
C# — display full audit trail summary as DataTable
// ── Audit conclusion: full chain of evidence ──
var bronzeClose = Convert.ToDouble(bronzeAudit.Rows[0]["close"]);
var silverRow = silverAudit.AsEnumerable()
.First(r => r.Field<DateTime>("date").ToString("yyyy-MM-dd") == "2026-01-29");
var silverClose = Convert.ToDouble(silverRow["close"]);
var auditSummary = new DataTable();
auditSummary.Columns.Add("step", typeof(string));
auditSummary.Columns.Add("source", typeof(string));
auditSummary.Columns.Add("close", typeof(double));
auditSummary.Columns.Add("evidence", typeof(string));
auditSummary.Rows.Add("1. Bronze table", "bronze_ohlcv", bronzeClose,
$"Batch {batchFromBronze[..8]}");
auditSummary.Rows.Add("2. Silver table", "silver_ohlcv", silverClose,
"Return = -16.07% verified");
auditSummary.Rows.Add("3. Gold table", "gold_daily_summary", DBNull.Value,
"min_return reflects the drop");
auditSummary.Rows.Add("4. Lineage", "lineage_stages", DBNull.Value,
$"Hash: {lineageAudit.Rows[0]["output_hash"]}");
auditSummary.Rows.Add("5. RunContext", $"run_{batchFromBronze[..8]}.json", DBNull.Value,
"0 rejected, status=completed");
Console.WriteLine("AUDIT CONCLUSION: The SAP.DE -16% drop on 2026-01-29 is AUTHENTIC.");
Console.WriteLine(" - Same close price in Bronze and Silver");
Console.WriteLine(" - Daily return verified mathematically from consecutive closes");
Console.WriteLine(" - Zero rows rejected by Pydantic validation");
Console.WriteLine(" - Output hash proves no post-ingestion tampering");
auditSummaryAUDIT CONCLUSION: The SAP.DE -16% drop on 2026-01-29 is AUTHENTIC.
- Same close price in Bronze and Silver
- Daily return verified mathematically from consecutive closes
- Zero rows rejected by Pydantic validation
- Output hash proves no post-ingestion tampering
| step | source | close | evidence |
|---|---|---|---|
| 1. Bronze table | bronze_ohlcv | 164.6199951171875 | Batch 9c135c08 |
| 2. Silver table | silver_ohlcv | 164.6199951171875 | Return = -16.07% verified |
| 3. Gold table | gold_daily_summary | min_return reflects the drop | |
| 4. Lineage | lineage_stages | Hash: 914eccd231d933a2 | |
| 5. RunContext | run_9c135c08.json | 0 rejected, status=completed |
Context-Driven Analysis — Metadata in Action
The three demonstrations below use real data from this pipeline run to show what context adds beyond lineage:
- Zero-volume classification — context + trading calendar turns 116 undifferentiated alerts into classified holidays vs genuine anomalies
- SMA-20 null accounting — context explains exactly how many nulls are expected per symbol and flags any that exceed the baseline
- Data contract interpretation — column-level metadata makes Gold values self-describing without reading the pipeline source code
Zero-Volume Classification — Holiday or Anomaly?
Silver contains rows with volume=0. Without context, each is an
undifferentiated alert. With the trading calendar cross-reference recorded at
bronze ingestion, each is classified as non-trading day or genuine anomaly.
// ── Context demonstration: zero-volume classification ──
var zeroVol = QueryToTable(
"SELECT DISTINCT symbol, date FROM silver_ohlcv WHERE volume = 0");
if (zeroVol.Rows.Count > 0)
{
var classified = new DataTable();
classified.Columns.Add("symbol", typeof(string));
classified.Columns.Add("date", typeof(DateTime));
classified.Columns.Add("is_trading_day", typeof(bool));
classified.Columns.Add("verdict", typeof(string));
foreach (DataRow row in zeroVol.Rows)
{
var isTradingDay = sqlConn.ExecuteScalar<bool?>(
$"SELECT is_trading_day FROM dim_calendar WHERE date = @Date AND exchange_code = 'XETR'",
new { Date = row["date"] });
classified.Rows.Add(row["symbol"], row["date"],
isTradingDay,
isTradingDay == true ? "anomaly" : "non-trading day");
}
var anomalies = classified.AsEnumerable().Count(r => r.Field<string>("verdict") == "anomaly");
var holidays = classified.AsEnumerable().Count(r => r.Field<string>("verdict") == "non-trading day");
classified.DefaultView.Sort = "date ASC";
var sorted = classified.DefaultView.ToTable();
// show first 5
var preview = sorted.Clone();
for (int i = 0; i < Math.Min(5, sorted.Rows.Count); i++)
preview.ImportRow(sorted.Rows[i]);
display(preview);
Console.WriteLine($"\nTotal zero-volume: {classified.Rows.Count} | " +
$"Non-trading days: {holidays} | Anomalies to investigate: {anomalies}");
}
else
{
Console.WriteLine($"No zero-volume rows in Silver");
}| symbol | date | is_trading_day | verdict |
|---|---|---|---|
| ALV.DE | 20-Sep-24 0:00:00 | non-trading day | |
| BAS.DE | 21-Oct-24 0:00:00 | non-trading day | |
| SAP.DE | 21-Oct-24 0:00:00 | non-trading day | |
| ALV.DE | 01-Nov-24 0:00:00 | non-trading day | |
| BAS.DE | 01-Nov-24 0:00:00 | non-trading day |
Total zero-volume: 116 | Non-trading days: 116 | Anomalies to investigate: 0
SMA-20 Null Accounting — Expected vs Unexpected
sma_20 requires 20 data points — the first 19 rows per symbol are NULL by
mathematical necessity. Context recorded this at silver stage. If any symbol
has MORE than 19 nulls, those extras are unexplained and need investigation.
// ── Context demonstration: SMA-20 null accounting ──
var smaNulls = QueryToTable(
@"SELECT symbol, COUNT(*) as null_count, MIN(date) as first_null, MAX(date) as last_null
FROM silver_ohlcv WHERE sma_20 IS NULL GROUP BY symbol ORDER BY symbol");
int actualNullCount = sqlConn.ExecuteScalar<int>("SELECT COUNT(*) FROM silver_ohlcv WHERE sma_20 IS NULL");
int symbolsCount = sqlConn.ExecuteScalar<int>("SELECT COUNT(DISTINCT symbol) FROM silver_ohlcv");
int expectedNullCount = 19 * symbolsCount;
// Add expected + unexplained columns
smaNulls.Columns.Add("expected", typeof(int));
smaNulls.Columns.Add("unexplained", typeof(int));
foreach (DataRow row in smaNulls.Rows)
{
row["expected"] = 19;
row["unexplained"] = Convert.ToInt32(row["null_count"]) - 19;
}
display(smaNulls);
Console.WriteLine($"\nExpected nulls: {expectedNullCount} (19 x {symbolsCount} symbols) | " +
$"Actual: {actualNullCount} | Unexplained: {actualNullCount - expectedNullCount}");
var smaWarnings = goldStageCtx.DataWarnings.Where(w => w.Contains("sma_20")).ToList();
if (smaWarnings.Any())
Console.WriteLine($"Context recorded: {smaWarnings[0]}");| symbol | null_count | first_null | last_null | expected | unexplained |
|---|---|---|---|---|---|
| ALV.DE | 19 | 28-Mar-24 0:00:00 | 25-Apr-24 0:00:00 | 19 | 0 |
| BAS.DE | 19 | 28-Mar-24 0:00:00 | 25-Apr-24 0:00:00 | 19 | 0 |
| DTE.DE | 19 | 28-Mar-24 0:00:00 | 25-Apr-24 0:00:00 | 19 | 0 |
| SAP.DE | 19 | 28-Mar-24 0:00:00 | 25-Apr-24 0:00:00 | 19 | 0 |
| SIE.DE | 19 | 28-Mar-24 0:00:00 | 25-Apr-24 0:00:00 | 19 | 0 |
Expected nulls: 95 (19 x 5 symbols) | Actual: 95 | Unexplained: 0
Context recorded: sma_20: 95 NULL values (first 19 rows per symbol)
Data Contract — Column Semantics as Structured Data
Each exported JSON Schema contract includes x-column-context with the
computation formula, source columns, unit, and null semantics for every
derived column. This is what turns volatility: 0.0187 into
“daily σ of close-to-close returns, annualize with √252 → 29.7%“.
// ── Context demonstration: data contract as structured metadata ──
var contractJson = File.ReadAllText(Path.Combine(EXPORT_DIR, "contracts", "gold_symbol_profile_contract.json"));
var contract = JsonSerializer.Deserialize<JsonElement>(contractJson);
var derived = new DataTable();
derived.Columns.Add("column", typeof(string));
derived.Columns.Add("description", typeof(string));
derived.Columns.Add("unit", typeof(string));
derived.Columns.Add("computation", typeof(string));
derived.Columns.Add("source_columns", typeof(string));
derived.Columns.Add("null_means", typeof(string));
foreach (var col in contract.GetProperty("x-column-context").EnumerateArray())
{
if (col.TryGetProperty("is_derived", out var isDerived) && isDerived.GetBoolean())
{
derived.Rows.Add(
col.GetProperty("name").GetString(),
col.GetProperty("description").GetString(),
col.GetProperty("unit").GetString(),
col.TryGetProperty("computation", out var comp) ? comp.GetString() : "—",
col.TryGetProperty("source_columns", out var src)
? string.Join(", ", src.EnumerateArray().Select(s => s.GetString())) : "—",
col.TryGetProperty("null_semantics", out var ns) ? ns.GetString() : "—"
);
}
}
derived| column | description | unit | computation | source_columns | null_means |
|---|---|---|---|---|---|
| total_trading_days | Number of trading days with data | count | count(*) per symbol | silver.date | not_applicable |
| avg_daily_return | Mean daily close-to-close return over full history | decimal_ratio | mean(daily_return) per symbol | silver.daily_return | not_applicable |
| volatility | Standard deviation of daily returns — annualize by multiplying by sqrt(252) | decimal_ratio | std(daily_return) per symbol | silver.daily_return | not_applicable |
| max_drawdown | Largest peak-to-trough decline in cumulative return (always negative or zero) | decimal_ratio | min(cumulative_return - running_max(cumulative_return)) per symbol | silver.daily_return | not_applicable |
| avg_volume | Mean daily trading volume over full history | count | mean(volume) per symbol | silver.volume | not_applicable |
| total_dividends | Sum of all dividends paid over full history | EUR | sum(dividends) per symbol | silver.dividends | not_applicable |
// ── Context demonstration: interpreting a Gold value ──
var volMeta = contract.GetProperty("x-column-context").EnumerateArray()
.First(c => c.GetProperty("name").GetString() == "volatility");
var germanProfiles = QueryToTable(
"SELECT symbol, volatility FROM gold_symbol_profile WHERE symbol LIKE '%.DE'");
var interpretation = new DataTable();
interpretation.Columns.Add("symbol", typeof(string));
interpretation.Columns.Add("daily_vol", typeof(double));
interpretation.Columns.Add("annual_vol_%", typeof(double));
interpretation.Columns.Add("unit", typeof(string));
interpretation.Columns.Add("formula", typeof(string));
foreach (DataRow row in germanProfiles.Rows)
{
var vol = Convert.ToDouble(row["volatility"]);
interpretation.Rows.Add(
row["symbol"],
Math.Round(vol, 4),
Math.Round(vol * Math.Sqrt(252) * 100, 1),
volMeta.GetProperty("unit").GetString(),
volMeta.GetProperty("computation").GetString()
);
}
display(interpretation);
Console.WriteLine($"\nContract says: '{volMeta.GetProperty("description").GetString()}'");| symbol | daily_vol | annual_vol_% | unit | formula |
|---|---|---|---|---|
| ALV.DE | 0.0118 | 18.8 | decimal_ratio | std(daily_return) per symbol |
| BAS.DE | 0.0175 | 27.8 | decimal_ratio | std(daily_return) per symbol |
| DTE.DE | 0.0132 | 21 | decimal_ratio | std(daily_return) per symbol |
| SAP.DE | 0.0189 | 30 | decimal_ratio | std(daily_return) per symbol |
| SIE.DE | 0.0192 | 30.5 | decimal_ratio | std(daily_return) per symbol |
C# Functional Pipeline Warnings
Mutable
classDTOs passed through pipeline stages allow silent state mutationIf a Bronze DTO is a
classwith public setters, any method in the transform chain can modify it. A Silver enrichment step that accidentally writes back a corrected value to the Bronze object corrupts the immutable source record.
Correct pattern
Declare all pipeline DTOs as
recordtypes. Usewithexpressions to produce a new instance with the changed field rather than mutating the existing one:var silverRow = bronzeRow with { CloseAdj = adjusted };.
Using string concatenation in Dapper queries creates SQL injection risk
Dapper executes whatever SQL string you provide. Building queries with
$"SELECT * FROM {tableName} WHERE symbol = '{symbol}'"is exploitable if any input comes from user-controlled or untrusted sources.
Correct pattern
Always use parameterized queries:
conn.Query<BronzeRow>("SELECT * FROM bronze WHERE symbol = @symbol", new { symbol }). Dapper maps named parameters safely.
HttpListeneris not safe to expose on a production internet-facing endpoint
HttpListenerhas no TLS termination, no authentication middleware, no rate limiting, and no routing beyond basic prefix matching. Binding it tohttp://+:8080/on a production server exposes an unprotected endpoint.
Correct pattern
Use
HttpListeneronly for local development, integration testing, or pipeline-internal communication. For production, replace it with ASP.NET Core Minimal API, which adds TLS, middleware, dependency injection, and OpenAPI generation.
Polly v7 and v8 builder APIs are incompatible and cannot coexist in the same call chain
Polly v8 introduced a breaking redesign —
Policy.Handle<>()...Build()is replaced bynew ResiliencePipelineBuilder()...Build(). Mixing the two in one project compiles but produces runtime exceptions.
Correct pattern
Standardize on one Polly version per project. For new code, use Polly v8 with
ResiliencePipelineBuilder<T>. Migrate old v7 policies incrementally by wrapping them asResiliencePipelinedelegates.
C# Functional Pipeline Recommendations
- Enforce immutability with
recordtypes for all DTOs. The compiler generatesEquals,GetHashCode, andwithfor free, and theinit-only setters prevent post-construction mutation without explicit copying. - Run FluentValidation rules before any I/O. Validate in memory first; only write to SQL Server after the validator returns no failures. A failed database write with a partial row is harder to clean up than a rejected in-memory object.
- Configure Polly retry with
ShouldHandleto distinguish transient from permanent errors. Retry onHttpRequestExceptionandSqlExceptionwith error code1205(deadlock); do not retry onJsonExceptionorValidationException— those are permanent failures. - Use
SqlBulkCopyfor Bronze inserts above ~1,000 rows. Dapper’sExecutewithINSERTstatements sends one round-trip per row.SqlBulkCopyuses the TDS bulk load protocol and is typically 10–50× faster for batch inserts. - Store the pipeline run ID in every Bronze and Silver row. This enables forensic Dapper queries that filter
WHERE run_id = @runIdto reconstruct exactly what a specific execution produced. - Pre-materialize all JSON before the
HttpListenerloop starts. SerializingJsonSerializer.Serialize(goldRows)once at startup is far cheaper than re-serializing on every request, especially under concurrent load. - Version FluentValidation rules alongside schema migrations. If a Silver validator’s rules change, Bronze rows that were valid under the previous rule set may fail under the new one — keep a changelog in the configuration constants section.
- Monitor the lineage table for missing run IDs. A Dapper query like
SELECT expected_date FROM schedule WHERE run_id IS NULLis the authoritative check that a pipeline executed — do not rely on log file presence alone.
C# Functional Pipeline Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
FluentValidation.ValidationException on Silver ingestion | Bronze row has a null or out-of-range value that passed initial landing | Add a field_validator equivalent (RuleFor(x => x.Close).GreaterThan(0)) and log rejected rows to a bronze_quarantine table |
SqlException: Cannot insert duplicate key in object 'bronze' | Incremental ingestion re-downloaded a date range already in the table | Add a WHERE NOT EXISTS (SELECT 1 FROM bronze WHERE symbol = @symbol AND date = @date) guard to the insert statement |
HttpListenerException: Access is denied when starting the serving layer | Windows requires explicit netsh URL reservation for non-localhost prefixes | Run netsh http add urlacl url=http://+:8080/ user=Everyone once, or change the prefix to http://localhost:8080/ for local use |
| Polly retry fires on every request even for non-transient errors | ShouldHandle predicate is too broad (matching all exceptions) | Narrow the predicate: handle only HttpRequestException and specific SqlException error codes; re-throw all others immediately |
| Parquet file not found when the API starts | Export step ran before the Gold writes were committed, or path constants diverged | Confirm PARQUET_PATH in the export step and the API startup code reference the same Path.Combine(BASE_DIR, ...) expression |
| SHA-256 mismatch for rows that were never manually modified | Trailing whitespace or encoding difference in a string field produces a different hash | Normalize all string fields to .Trim().ToLowerInvariant() before hashing; document the normalization contract next to the hash utility method |
Dapper InvalidOperationException: Sequence contains no elements | QuerySingle used on a query that returned zero rows | Replace QuerySingle with QuerySingleOrDefault and check for null before using the result |
JsonException: The JSON value could not be converted to System.Double | Gold row has a NaN or Infinity value (from a zero-price division) that System.Text.Json cannot serialize | Guard against division by zero in the Gold aggregation step; replace invalid doubles with null before serialization |
Contract says: 'Standard deviation of daily returns — annualize by multiplying by sqrt(252)'