Building a Securities Master — Part 1 of 6

Foundations — Architecture for the Autonomous AI Agent

Design a production-grade securities master database to feed a systematic trading infrastructure. Empower the Python clustering algorithms and overarching AI Finance Agent with point-in-time correctness.

PostgreSQL Go Engine Python Discriminants AI Strategy Agent Zero Bias Point-in-Time
Securities Master1/6
Why a Securities Master?

The Single Source of Truth Problem

Every quantitative team eventually hits the same wall. Your backtest shows 15% annualized returns, but live trading delivers 8%. Your analyst pulls Tesla's P/E from one spreadsheet while your risk model reads it from another—and they disagree. You discover that your "complete" dataset of S&P 500 constituents has only 450 names because delisted companies silently disappeared.

A securities master solves this by being the single, canonical source of truth for all instrument metadata, price history, fundamentals, and corporate actions. It answers one deceptively simple question: what did we know about security X at time T?

The AI Strategy Engine Context: Our overarching architecture relies on a central AI Finance Agent. This agent continuously scans the market using a high-performance Go opportunity-analyzer, exporting top candidates alongside the full universe. It then uses Python clustering to find discriminants (metrics like VolRatio or RSI) that separate winners from losers. If this AI is fed uncorrupted, point-in-time data, it discovers incredible mathematical edge. If fed biased data, it will flawlessly optimize for your financial destruction.

Survivorship Bias

Delisted companies vanish from datasets, inflating backtested returns by 2–4% annually

Look-Ahead Bias

Restated financials silently overwrite originals. Your backtest uses data that didn't exist yet

Data Chaos

CSV files, API caches, spreadsheets, ad-hoc scripts. No versioning, no lineage, no tests

ID Fragmentation

AAPL on Yahoo, US0378331005 on Bloomberg, 037833100 CUSIP. Same security, different keys everywhere

The Cost of Getting This Wrong

A 2019 study by Dimson, Marsh & Staunton found that survivorship bias alone inflates long-term equity return estimates by 2.2% per year. Over a 25-year backtest, that compounds to a 70% overestimate of terminal wealth. If your securities master doesn't track delistings, your backtests are fiction.

Bias Deep Dive

Point-in-Time & SCD Type 2

The core architectural decision of our securities master is point-in-time correctness. Every row must answer: "What was the value of this field at this moment?" Not what it is today, not what it was restated to—what was actually known.

Slowly Changing Dimensions (SCD Type 2)

SCD Type 2 is the standard approach. Instead of overwriting a row when data changes, you close the old row (set valid_to) and insert a new one. This gives you a complete audit trail. When Apple's sector changed from "Consumer Electronics" to "Technology" in GICS reclassifications, both facts are preserved.

SQL — SCD Type 2 Pattern
-- The instruments table stores every version of every security
-- valid_from/valid_to define when this version was "current"

SELECT symbol, name, sector, exchange,
       valid_from, valid_to
FROM   instruments
WHERE  symbol = 'AAPL'
ORDER BY valid_from;

-- Result:
-- symbol | name      | sector               | valid_from | valid_to
-- AAPL   | Apple Inc | Consumer Electronics | 2000-01-01 | 2018-09-28
-- AAPL   | Apple Inc | Technology           | 2018-09-28 | NULL

Querying "As Of" Any Date

The killer feature of SCD Type 2 is the as-of query. A single WHERE clause gives you a perfect snapshot of reality at any point in time.

SQL — As-Of Query
-- What did we know about AAPL on 2017-06-15?
SELECT *
FROM   instruments
WHERE  symbol = 'AAPL'
  AND  valid_from <= '2017-06-15'
  AND  (valid_to > '2017-06-15' OR valid_to IS NULL);

-- This returns the "Consumer Electronics" version
-- because the reclassification happened in Sep 2018

Design Decision: Why valid_to IS NULL?

The current version of any record has valid_to = NULL instead of a far-future sentinel date like 9999-12-31. This is deliberate: NULL semantics in SQL mean "unknown end" which is exactly what we mean for a currently-valid record. It also makes partial indexes on valid_to IS NULL extremely efficient for "give me the latest version" queries.

The Fundamentals Problem: Restatements

Earnings are the worst offender for look-ahead bias. A company reports Q3 EPS of $1.20 on November 5th. Three months later, they restate it to $1.05. If your database only stores the restated value, your backtest on November 6th "sees" $1.05—data that didn't exist until February.

SQL — Point-in-Time Fundamentals
-- Fundamentals table preserves every reported AND restated value
SELECT symbol, period, metric, value,
       reported_at,    -- when this value was first published
       superseded_at   -- when a restatement replaced it (NULL = current)
FROM   fundamentals
WHERE  symbol = 'ENRON'
  AND  period = '2001-Q3'
  AND  metric = 'eps'
ORDER BY reported_at;

-- Result:
-- value | reported_at | superseded_at
-- 0.34  | 2001-10-16  | 2001-11-08    -- original report
-- -0.84 | 2001-11-08  | NULL          -- restated (massive loss)
SQL — Generic As-Of Function
-- Reusable pattern: "what fundamentals were known at date X?"
SELECT symbol, period, metric, value
FROM   fundamentals
WHERE  symbol = 'ENRON'
  AND  reported_at <= '2001-10-20'::date   -- as of this date
  AND  (superseded_at > '2001-10-20'::date
        OR superseded_at IS NULL);

-- Returns EPS = 0.34 (the original, not-yet-restated value)
-- This is exactly what a trader on Oct 20 would have seen

Visualizing Survivorship Bias

The chart below shows how survivorship bias compounds over time. The blue line is a backtest that includes delisted companies (correct). The red line excludes them (biased). After 25 years, the biased backtest overstates returns by 70%.

Why Does This Matter for You?

If you're running backtests on Yahoo Finance data, you have survivorship bias. Yahoo only shows currently-listed companies. Every Enron, Lehman, WorldCom, and Wirecard has vanished. Your "historical S&P 500" dataset has 500 winners and zero losers. The securities master fixes this by never deleting data—delistings are recorded as events, not erasures.

Architecture Overview

End-to-End Data Flow

The system follows the ELT pattern (Extract-Load-Transform): raw data is loaded into PostgreSQL first, then dbt transforms it into analytical models. This separates concerns cleanly and makes the pipeline debuggable—you can always inspect the raw data.

Data Flow
Sources → Meltano (EL) → PostgreSQL (raw) → dbt (T) → PostgreSQL (mart) → Evidence.dev

The Five Layers

1

Sources

Yahoo Finance, SEC EDGAR, NASDAQ listings, exchanges reference data, corporate actions feeds

2

Extract & Load

Meltano orchestrates Singer taps. Raw JSON/CSV lands in PostgreSQL raw_* schemas unchanged

3

Transform

dbt models clean, deduplicate, apply SCD2 logic, partition by date, and run 200+ tests

4

Serve

PostgreSQL mart schema with materialized views. Sub-10ms as-of queries via partial indexes

5

Visualize

Evidence.dev dashboards for data quality monitoring, coverage reports, and anomaly detection

Design Decision: ELT, not ETL

We load raw data first, then transform. This means when a dbt model has a bug, you fix the SQL and re-run—the raw data is still there. In ETL, if the transform corrupts data before loading, you have to re-extract from the source. ELT is inherently more debuggable and recoverable.

Stack Decisions

Why This Stack?

Every component was chosen after evaluating alternatives. Here's the reasoning.

Orchestration: Nomad vs Kubernetes vs Airflow

Criteria Nomad Kubernetes Airflow
Operational complexity Single binary, 1 config file etcd + API server + scheduler + kubelet Webserver + scheduler + workers + DB
Resource overhead ~50 MB RAM ~500 MB RAM (control plane) ~1 GB RAM minimum
Job types Batch, service, system, periodic Pod, Job, CronJob, Deployment DAGs only (task orchestration)
Periodic jobs (cron) Native periodic stanza CronJob resource Native DAG scheduling
Multi-region Native federation Requires Istio/Submariner CeleryExecutor + broker
Learning curve 1 day 1–3 months 1–2 weeks

Decision: Nomad

For a financial data pipeline, you need cron-like scheduling and service management. Nomad does both in a single 50MB binary with no external dependencies. Kubernetes is overkill—we're not running a microservices fleet. Airflow is Python-centric and adds its own metadata database and webserver, neither of which we need.

Storage: PostgreSQL vs ClickHouse vs TimescaleDB

Criteria PostgreSQL ClickHouse TimescaleDB
SCD Type 2 support Native (UPDATE, partial indexes) Poor (immutable, merge tree) PG extension (native)
ACID transactions Full Eventual consistency Full (PG-based)
Analytical queries Good (parallel, JIT) Excellent (columnar) Good + hypertables
dbt support First-class Community adapter PG adapter works
Partitioning Declarative (native) Automatic (MergeTree) Hypertables (automatic)
Operational cost Mature, well-known Niche, Zookeeper dep PG + extension

Decision: PostgreSQL

SCD Type 2 requires UPDATE operations (closing old rows). ClickHouse's immutable MergeTree engine makes this painful—you need ReplacingMergeTree with deduplication delays. PostgreSQL gives us real ACID, partial indexes on valid_to IS NULL, and native partitioning for time-series data. TimescaleDB adds hypertable overhead we don't need since our partitioning scheme is simple (monthly by trade date).

Transform: dbt vs SQLMesh vs custom SQL

Criteria dbt SQLMesh Custom SQL scripts
Testing framework 200+ built-in tests + custom Audits (similar) Manual
Documentation Auto-generated DAG + docs Auto-generated None
Community / ecosystem Largest (dbt Hub, packages) Growing N/A
Incremental models Native Native + virtual envs Manual
CI/CD integration dbt Cloud or CLI CLI-first Shell scripts

Decision: dbt

dbt's testing framework is the main driver. We need guaranteed data quality: no NULL prices, no duplicate bars, no gaps in time series. dbt's unique, not_null, accepted_values, and custom SQL tests run after every transform. SQLMesh is a viable alternative but has a smaller ecosystem and fewer community packages for financial data patterns.

Extract & Load: Meltano vs Airbyte vs custom scripts

Decision: Meltano

Meltano is declarative (YAML), CLI-first, and integrates natively with dbt. Airbyte requires a Docker-based server (Temporal + workers + webapp), which is more infrastructure than the actual pipeline. For financial data, we write custom Singer taps (Python) that Meltano orchestrates. The Singer spec is simple: a tap emits JSON lines, a target loads them.

Data Model

Core Tables

The data model has six core tables. Each one serves a specific purpose and has specific design decisions behind it.

1. exchanges — Reference Data

The anchor table. Every instrument belongs to an exchange. This is the simplest table because exchange metadata rarely changes.

SQL — DDL
CREATE TABLE exchanges (
    exchange_id  SMALLINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    mic          CHAR(4)  NOT NULL UNIQUE,  -- ISO 10383 Market Identifier Code
    name         TEXT     NOT NULL,
    country      CHAR(2)  NOT NULL,         -- ISO 3166-1 alpha-2
    timezone     TEXT     NOT NULL,
    open_time    TIME,
    close_time   TIME,
    currency     CHAR(3)  NOT NULL,         -- ISO 4217
    region       TEXT     NOT NULL           -- 'US', 'EU', 'APAC', 'LATAM', 'MEA'
);

Decision: SMALLINT primary key

There are ~250 major exchanges globally. SMALLINT (2 bytes, max 32,767) is sufficient and saves 6 bytes per row vs BIGINT in every foreign key reference. With billions of bar rows referencing this table, that adds up to gigabytes of savings.

2. instruments — SCD Type 2

The heart of the securities master. Every security, across all asset classes and regions, lives here. SCD Type 2 means every metadata change creates a new version.

SQL — DDL
CREATE TABLE instruments (
    instrument_id   BIGINT GENERATED ALWAYS AS IDENTITY,  -- surrogate key (version-specific)
    instrument_key  UUID        NOT NULL,                 -- business key (stable across versions)

    -- Identifiers (multi-system)
    symbol       TEXT     NOT NULL,   -- ticker: AAPL, 7203.T, AIR.PA
    isin         CHAR(12),           -- ISO 6166
    cusip        CHAR(9),            -- US/CA only
    sedol        CHAR(7),            -- UK/IE only
    figi         CHAR(12),           -- OpenFIGI

    -- Hedge-Fund Metadata
    name             TEXT     NOT NULL,
    instrument_type  VARCHAR(50) NOT NULL, -- 'EQUITY', 'FUTURE', 'FXFORWARD', 'IRS', 'OPTIONVOL', 'CRYPTO'
    sector           TEXT,                 -- GICS sector
    industry         TEXT,                 -- GICS industry
    exchange_id      SMALLINT REFERENCES exchanges(exchange_id),
    currency         CHAR(3)  NOT NULL,
    tick_size        NUMERIC(15, 6),
    is_tradable      BOOLEAN  DEFAULT TRUE,

    -- SCD Type 2 versioning (No overlap constraint!)
    valid_from   TIMESTAMPTZ NOT NULL DEFAULT now(),
    valid_to     TIMESTAMPTZ,         -- NULL = currently valid
    
    EXCLUDE USING gist (
        instrument_key WITH =, 
        tstzrange(valid_from, COALESCE(valid_to, 'infinity')) WITH &&
    ),

    PRIMARY KEY (instrument_id)
);

-- Partial index: fast "current version" lookups
CREATE INDEX idx_instruments_current
    ON instruments (symbol)
    WHERE valid_to IS NULL;

Decision: instrument_key (UUID) vs instrument_id (BIGINT)

We need two keys. The instrument_key (UUID) is the business key—it identifies the security across all versions. Apple always has the same UUID. The instrument_id (BIGINT) is the surrogate key—it identifies a specific version. When Apple's sector changes, a new instrument_id is created but the UUID stays the same. Bars and fundamentals reference instrument_key, not instrument_id, because price data isn't version-specific.

Decision: Multiple Identifier Columns & Instrument Types

We specifically model instrument_type to natively support hedge fund operations beyond stocks (e.g. FUTURE, FXFORWARD, IRS). ISIN, FIGI, and CUSIP are all nullable because no single identifier system covers all asset classes. Notice the EXCLUDE USING gist constraint: this mathematically prevents any two versions of the same instrument from overlapping in time.

2b. accounts & strategies — Trading Architecture

A true securities master doesn't just hold reference data; it maps instruments to the strategies permitted to trade them, allocating risk to correct prime brokers. Here is the foundation of execution integration.

SQL — DDL
CREATE TABLE accounts (
    id                 BIGINT PRIMARY KEY,
    name               VARCHAR NOT NULL UNIQUE,
    mifid_lei          VARCHAR NOT NULL,
    operator_id        VARCHAR,
    is_locates_enabled BOOLEAN NOT NULL DEFAULT FALSE,
    skip_start_of_day_position BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE TABLE strategies (
    id              BIGINT PRIMARY KEY,
    name            VARCHAR NOT NULL UNIQUE,
    account_id      BIGINT  NOT NULL REFERENCES accounts(id),
    trading_enabled BOOLEAN NOT NULL
);

2c. ExecutionReport — The OMS Journal

Finally, the Securities Master is the backbone for the Order Management System (OMS). When Kafka messages flow in about filled orders, they join against our instruments via SecurityID.

SQL — DDL
CREATE TABLE ExecutionReport (
    id                  BIGINT PRIMARY KEY,
    InternalOrdID       VARCHAR(40),
    ExecID              VARCHAR(30),
    OrdStatus           VARCHAR(15),
    Side                VARCHAR(15),
    
    SecurityID          VARCHAR(40),   -- Maps to instruments.symbol or isin
    SecurityIDSource    VARCHAR(20),   -- e.g. 'EXCHANGE', 'BLOOMBERG'
    
    LeavesQty           FLOAT8,
    CumQty              FLOAT8,
    AvgPx               FLOAT8,
    Account             VARCHAR(12),
    Strategy            VARCHAR(23),
    
    TradeDate           VARCHAR(20),
    InternalAppTimestamp TIMESTAMP
);

3. bars_daily — Partitioned Time Series

The largest table by far. For 10,000 instruments over 25 years, this is ~60M rows. Partitioning by month keeps each partition at ~200K rows, which fits entirely in RAM for fast scans.

SQL — DDL
CREATE TABLE bars_daily (
    instrument_key  UUID           NOT NULL,
    trade_date      DATE           NOT NULL,
    open            NUMERIC(14,4)  NOT NULL,
    high            NUMERIC(14,4)  NOT NULL,
    low             NUMERIC(14,4)  NOT NULL,
    close           NUMERIC(14,4)  NOT NULL,
    volume          BIGINT         NOT NULL,
    adj_close       NUMERIC(14,4),            -- split/dividend adjusted
    vwap            NUMERIC(14,4),            -- volume-weighted avg price
    trade_count     INTEGER,                  -- number of trades (if available)
    source          TEXT           NOT NULL DEFAULT 'yahoo',
    loaded_at       TIMESTAMPTZ    NOT NULL DEFAULT now(),

    PRIMARY KEY (instrument_key, trade_date)
) PARTITION BY RANGE (trade_date);

-- Partitions are created automatically by a Nomad periodic job
-- Example: one partition per month
CREATE TABLE bars_daily_2026_01 PARTITION OF bars_daily
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE bars_daily_2026_02 PARTITION OF bars_daily
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

Decision: NUMERIC(14,4) for Prices

FLOAT/DOUBLE introduce rounding errors. 0.1 + 0.2 != 0.3 in floating point, which is unacceptable for financial calculations. NUMERIC is exact decimal arithmetic. 14 total digits with 4 decimal places supports prices up to $9,999,999,999.9999, covering everything from penny stocks to Berkshire Hathaway Class A ($600K+).

Decision: Monthly Partitioning

Why monthly, not yearly or daily? Yearly partitions are too large (~2.5M rows for 10K instruments)—constraint exclusion becomes slow. Daily partitions create too many tables (~6,500 for 25 years)—the PostgreSQL catalog bloats and planning time increases. Monthly is the sweet spot: ~200K rows per partition, ~300 partitions for 25 years, each fitting in memory for fast sequential scans.

4. fundamentals — Point-in-Time

Earnings, revenue, margins, balance sheet items. Uses reported_at/superseded_at for point-in-time correctness instead of SCD2 (because fundamentals are period-based, not entity-based).

SQL — DDL
CREATE TABLE fundamentals (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    instrument_key  UUID         NOT NULL,
    period          TEXT         NOT NULL,   -- '2025-Q3', '2025-FY'
    period_end      DATE         NOT NULL,   -- fiscal period end date
    metric          TEXT         NOT NULL,   -- 'eps', 'revenue', 'net_income', 'fcf', ...
    value           NUMERIC      NOT NULL,
    currency        CHAR(3)      NOT NULL,

    -- Point-in-time tracking
    reported_at     TIMESTAMPTZ  NOT NULL,   -- when this value became known
    superseded_at   TIMESTAMPTZ,             -- when a restatement replaced it
    source          TEXT         NOT NULL,
    loaded_at       TIMESTAMPTZ  NOT NULL DEFAULT now(),

    UNIQUE (instrument_key, period, metric, reported_at)
);

-- Point-in-time index
CREATE INDEX idx_fundamentals_pit
    ON fundamentals (instrument_key, metric, reported_at, superseded_at);

Decision: EAV (Entity-Attribute-Value) for Metrics

Instead of wide tables with 200 columns (eps, revenue, net_income...), we use a narrow metric + value pattern. This makes the schema extensible—adding a new metric (say, adjusted_ebitda) requires zero DDL changes. The trade-off is pivot queries are harder, but dbt macros handle that elegantly in the mart layer.

5. corporate_actions — Event Sourcing

Splits, dividends, mergers, delistings. Each action is an immutable event with a specific effective date.

SQL — DDL
CREATE TABLE corporate_actions (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    instrument_key  UUID   NOT NULL,
    action_type     TEXT   NOT NULL,  -- 'split', 'dividend', 'merger', 'spinoff', 'delist', 'name_change'
    effective_date  DATE   NOT NULL,
    announced_date  DATE,             -- when the market learned about it
    ex_date         DATE,             -- ex-dividend/ex-split date
    record_date     DATE,

    -- Action-specific data (JSONB for flexibility)
    details  JSONB  NOT NULL DEFAULT '{}',
    -- split:    {"ratio": "4:1", "factor": 4.0}
    -- dividend: {"amount": 0.82, "currency": "USD", "type": "regular"}
    -- merger:   {"acquirer_key": "uuid", "terms": "0.7 shares per share"}
    -- delist:   {"reason": "bankruptcy", "last_trade_date": "2008-09-15"}

    source     TEXT         NOT NULL,
    loaded_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE INDEX idx_corpactions_instrument
    ON corporate_actions (instrument_key, effective_date);

Decision: JSONB for Action Details

Corporate actions are inherently polymorphic. A split has a ratio, a dividend has an amount and type, a merger has acquirer terms. Separate tables for each type creates a sprawling schema. JSONB keeps it in one table with flexible querying: details->>'ratio' for splits, (details->>'amount')::numeric for dividends. PostgreSQL can index JSONB fields using GIN indexes if needed.

6. index_constituents — Composition History

Which securities were in which index on which date. Critical for avoiding survivorship bias in index-relative backtests.

SQL — DDL
CREATE TABLE index_constituents (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    index_symbol    TEXT         NOT NULL,   -- 'SPX', 'NDX', 'STOXX600', 'TOPIX'
    instrument_key  UUID         NOT NULL,
    added_date      DATE         NOT NULL,
    removed_date    DATE,                    -- NULL = still a constituent
    weight          NUMERIC(8,6),            -- index weight at addition
    source          TEXT         NOT NULL,
    loaded_at       TIMESTAMPTZ  NOT NULL DEFAULT now(),

    UNIQUE (index_symbol, instrument_key, added_date)
);

-- "What was in the S&P 500 on 2008-09-12?"
-- (one trading day before Lehman Brothers collapsed)
SELECT i.symbol, i.name, ic.weight
FROM   index_constituents ic
JOIN   instruments i ON i.instrument_key = ic.instrument_key
  AND  i.valid_from <= '2008-09-12'
  AND  (i.valid_to > '2008-09-12' OR i.valid_to IS NULL)
WHERE  ic.index_symbol = 'SPX'
  AND  ic.added_date <= '2008-09-12'
  AND  (ic.removed_date > '2008-09-12' OR ic.removed_date IS NULL)
ORDER BY ic.weight DESC;

-- This query returns Lehman Brothers (LEH) as a constituent
-- because it wasn't removed until after bankruptcy

The Universal As-Of Pattern

Every temporal table in the system follows the same pattern. You can query any data as of any date with this template:

SQL — Universal As-Of Template
-- Template: retrieve the state of any temporal table at @as_of_date
-- Works for: instruments, fundamentals, index_constituents

SELECT *
FROM   <table>
WHERE  <entity_key> = @entity
  AND  <valid_from_col> <= @as_of_date
  AND  (<valid_to_col> > @as_of_date OR <valid_to_col> IS NULL);

-- For bars_daily, it's simpler (no SCD, just filter by date):
SELECT * FROM bars_daily
WHERE  instrument_key = @key AND trade_date <= @as_of_date
ORDER BY trade_date DESC LIMIT 1;

-- For fundamentals (point-in-time):
SELECT * FROM fundamentals
WHERE  instrument_key = @key
  AND  metric = @metric
  AND  reported_at <= @as_of_date
  AND  (superseded_at > @as_of_date OR superseded_at IS NULL);

Expected Data Volume

Here's the estimated row count for a multi-region universe of ~10,000 instruments with 25 years of history.

Star Schema — BI & Data-Driven Strategy

From OLTP to Analytical Star Model

The core tables we designed above are OLTP-optimized—normalized, SCD Type 2, partitioned for fast writes. But analysts and portfolio managers don't want to join 5 tables to answer “what was my sector exposure last Tuesday?” That’s where the star schema comes in: a denormalized mart layer built by dbt, designed for fast reads and composable BI queries.

Design Decision — Star vs. Snowflake

Star wins for BI. Snowflake schema (normalized dimensions) saves disk at the cost of query complexity. In a securities master, dimensions are small (~10K instruments, 250 exchanges, ~9K trading days). The denormalization overhead is negligible, but the query simplicity is massive. Every BI query becomes a single JOIN from fact to dimension—exactly the pattern that Evidence.dev, Metabase, and LLM-generated SQL work best with.

Design Decision — Materialized Views vs. Physical Tables

We use dbt-managed materialized tables. The dimensions and facts are dbt models materialized as materialized='table' in the mart schema. dbt handles refresh orchestration, lineage tracking, and test contracts. This means the star schema is always derivable from the OLTP layer—no separate ETL pipeline to maintain. If a dbt model breaks, fix the SQL and dbt run. The raw data is untouched.

Dimension Tables

Dimensions are denormalized snapshots extracted from the SCD2 tables. They represent the current state by default, but we also maintain a dim_instrument_history SCD view for as-of BI queries.

SQL — mart.dim_date (calendar dimension)
-- The backbone of any star schema: a calendar dimension
-- Pre-computed columns eliminate date math in every query

CREATE TABLE mart.dim_date (
    date_key          DATE PRIMARY KEY,
    year              INT,
    quarter           INT,
    month             INT,
    week_of_year      INT,
    day_of_week       INT,       -- 1=Monday, 7=Sunday
    day_name          TEXT,      -- 'Monday', 'Tuesday', ...
    is_trading_day    BOOLEAN,   -- FALSE for weekends + market holidays
    is_month_end      BOOLEAN,
    is_quarter_end    BOOLEAN,
    is_opex           BOOLEAN,   -- 3rd Friday (options expiration)
    is_fomc           BOOLEAN,   -- Fed announcement days
    trading_days_ytd  INT        -- cumulative trading days in year
);

-- Populate: generate_series + join to market holidays table
-- Covers 2000-01-01 to 2030-12-31 = ~11,000 rows
SQL — mart.dim_instrument (denormalized instrument dimension)
-- Flattened view of current instrument state
-- Joins instruments + exchanges in one row per active security

CREATE TABLE mart.dim_instrument AS
SELECT
    i.instrument_key,
    i.symbol,
    i.name,
    i.asset_type,
    i.sector,
    i.industry,
    i.country,
    i.currency,
    i.is_active,
    i.listed_date,
    i.delisted_date,
    e.exchange_code,
    e.exchange_name,
    e.timezone  AS exchange_timezone,
    e.region    AS exchange_region
FROM instruments i
JOIN exchanges e ON e.exchange_id = i.exchange_id
WHERE i.valid_to IS NULL;  -- current SCD2 row only

-- ~10,000 rows (one per instrument)
-- Refreshed daily by dbt: dbt run --select mart.dim_instrument
SQL — mart.dim_sector (sector/industry hierarchy)
CREATE TABLE mart.dim_sector AS
SELECT DISTINCT
    sector,
    industry,
    COUNT(*) OVER (PARTITION BY sector)   AS instruments_in_sector,
    COUNT(*) OVER (PARTITION BY industry) AS instruments_in_industry
FROM mart.dim_instrument
WHERE is_active = TRUE;

Fact Tables

Fact tables store measures at the grain of one event per row. Each fact row links to dimension keys via foreign keys. We build two core facts from the OLTP layer.

SQL — mart.fact_daily_returns (the core analytical fact)
-- One row per instrument per trading day
-- Pre-computes daily returns, cumulative returns, drawdown
-- This is the table 90% of BI queries will hit

CREATE TABLE mart.fact_daily_returns (
    date_key         DATE           NOT NULL REFERENCES mart.dim_date(date_key),
    instrument_key   UUID           NOT NULL,
    open             NUMERIC(14,4),
    high             NUMERIC(14,4),
    low              NUMERIC(14,4),
    close            NUMERIC(14,4),
    volume           BIGINT,
    adj_close        NUMERIC(14,4),

    -- Pre-computed analytics
    daily_return     NUMERIC(10,6),   -- (close - prev_close) / prev_close
    log_return       NUMERIC(10,6),   -- ln(close / prev_close)
    cum_return_ytd   NUMERIC(10,6),   -- cumulative return since Jan 1
    drawdown         NUMERIC(10,6),   -- current drawdown from peak
    rolling_vol_20d  NUMERIC(10,6),   -- 20-day rolling annualized vol
    sma_50           NUMERIC(14,4),
    sma_200          NUMERIC(14,4),
    rsi_14           NUMERIC(6,2),

    PRIMARY KEY (date_key, instrument_key)
) PARTITION BY RANGE (date_key);

-- Grain: 1 row per instrument per trading day
-- Estimated: ~60M rows (same as bars_daily)
-- Partitioned monthly, same scheme as bars_daily

Design Decision — Pre-compute Returns in the Fact Table

Pre-compute, don’t calculate on-the-fly. Window functions like LAG(close) OVER (PARTITION BY instrument_key ORDER BY date_key) on 60M rows take 15+ seconds. By pre-computing daily_return, drawdown, and rolling_vol during the dbt build, every BI query becomes a simple filter+aggregate. The dbt build pays the cost once; analysts get sub-second response.

SQL — mart.fact_portfolio_snapshot (daily portfolio mark-to-market)
-- One row per position per day
-- Built from trades table (see Trading Journal section)
-- Enables P&L attribution, sector exposure, risk decomposition

CREATE TABLE mart.fact_portfolio_snapshot (
    date_key        DATE           NOT NULL REFERENCES mart.dim_date(date_key),
    instrument_key  UUID           NOT NULL,
    portfolio_id    UUID           NOT NULL,
    quantity        NUMERIC(16,6),
    avg_cost        NUMERIC(14,4),
    market_price    NUMERIC(14,4),   -- close from bars_daily
    market_value    NUMERIC(16,4),   -- quantity * market_price
    unrealized_pnl  NUMERIC(16,4),   -- market_value - (quantity * avg_cost)
    weight_pct      NUMERIC(8,4),    -- position weight in portfolio
    daily_pnl       NUMERIC(16,4),   -- change from previous day

    PRIMARY KEY (date_key, portfolio_id, instrument_key)
) PARTITION BY RANGE (date_key);

-- Built nightly by dbt from trades + bars_daily
-- Enables: "What was my sector exposure on March 15?"

Star Schema Diagram

The complete mart layer forms a clean star: fact tables at the center, dimension tables radiating outward. Every BI query is a fact-to-dimension join.

Example BI Queries

With the star schema in place, complex analytical questions become simple SQL.

SQL — Sector rotation heatmap (monthly returns by sector)
SELECT
    d.year,
    d.month,
    di.sector,
    AVG(f.daily_return) * 21  AS monthly_return,
    AVG(f.rolling_vol_20d)    AS avg_volatility
FROM mart.fact_daily_returns f
JOIN mart.dim_date d       ON d.date_key = f.date_key
JOIN mart.dim_instrument di ON di.instrument_key = f.instrument_key
WHERE d.year = 2025
  AND di.is_active = TRUE
  AND di.asset_type = 'equity'
GROUP BY d.year, d.month, di.sector
ORDER BY d.year, d.month, monthly_return DESC;
SQL — Maximum drawdown by region, current year
SELECT
    di.exchange_region,
    di.symbol,
    di.name,
    MIN(f.drawdown)         AS max_drawdown,
    MAX(f.rolling_vol_20d)  AS peak_volatility
FROM mart.fact_daily_returns f
JOIN mart.dim_date d        ON d.date_key = f.date_key
JOIN mart.dim_instrument di ON di.instrument_key = f.instrument_key
WHERE d.year = 2025
GROUP BY di.exchange_region, di.symbol, di.name
HAVING MIN(f.drawdown) < -0.20  -- only stocks with >20% drawdown
ORDER BY max_drawdown ASC LIMIT 20;
SQL — Portfolio exposure as-of any date
SELECT
    di.sector,
    SUM(ps.market_value)    AS sector_value,
    SUM(ps.weight_pct)      AS sector_weight,
    SUM(ps.unrealized_pnl)  AS sector_pnl
FROM mart.fact_portfolio_snapshot ps
JOIN mart.dim_instrument di ON di.instrument_key = ps.instrument_key
WHERE ps.date_key = '2025-06-15'
  AND ps.portfolio_id = '...'
GROUP BY di.sector
ORDER BY sector_weight DESC;

The Star Schema Advantage

Notice how every query above follows the same pattern: SELECT from fact JOIN dim WHERE dim.attribute = filter GROUP BY dim.attribute. This is intentional. The star schema transforms ad-hoc analytical questions into a predictable, composable SQL pattern. Evidence.dev dashboards, BI tools like Metabase, and even LLM-generated SQL all work best with this consistent structure.

Trading Journal

Structured Trading Journal

A securities master isn’t just for backtesting—it’s the backbone of your live trading infrastructure. By embedding the trading journal directly in the database, every trade automatically inherits the instrument metadata, price history, and fundamentals context. No more disconnected spreadsheets.

Design Decision — Journal in the Securities Master vs. Separate System

Same database, different schema. The trading journal lives in a journal schema alongside the raw and mart schemas. This gives it direct foreign-key access to instruments and enables JOIN-based analytics (e.g., “what was the RSI when I entered this trade?”) without any ETL. The journal tables are lightweight (<100K rows for most traders) and don’t impact the time-series workload.

Core Tables

SQL — journal.portfolios
-- A trader may have multiple portfolios (swing, long-term, options)

CREATE TABLE journal.portfolios (
    portfolio_id     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name             TEXT NOT NULL,
    description      TEXT,
    strategy         TEXT,             -- 'momentum', 'value', 'mean-reversion'
    base_currency    TEXT DEFAULT 'USD',
    initial_capital  NUMERIC(16,4),
    created_at       TIMESTAMPTZ DEFAULT now()
);
SQL — journal.trades (every execution)
-- Immutable log of every trade execution
-- One row per fill (a single order may have multiple fills)

CREATE TABLE journal.trades (
    trade_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    portfolio_id    UUID           NOT NULL REFERENCES journal.portfolios(portfolio_id),
    instrument_key  UUID           NOT NULL,   -- FK to instruments.instrument_key
    trade_date      DATE           NOT NULL,
    trade_time      TIMESTAMPTZ,
    side            TEXT           NOT NULL,    -- 'buy', 'sell', 'short', 'cover'
    quantity        NUMERIC(16,6)  NOT NULL,
    price           NUMERIC(14,4)  NOT NULL,
    commission      NUMERIC(10,4)  DEFAULT 0,
    slippage        NUMERIC(10,4)  DEFAULT 0,

    -- Trade context (captured at entry time)
    setup_type    TEXT,             -- 'breakout', 'pullback', 'reversal', 'earnings'
    entry_reason  TEXT,             -- free-text thesis
    stop_loss     NUMERIC(14,4),   -- planned stop
    target_1      NUMERIC(14,4),   -- first target
    target_2      NUMERIC(14,4),   -- second target
    risk_reward   NUMERIC(6,2),    -- planned R:R at entry

    -- Metadata
    broker      TEXT,
    order_type  TEXT,               -- 'market', 'limit', 'stop_limit'
    tags        TEXT[],             -- ['momentum', 'FOMC', 'earnings-play']
    created_at  TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_trades_portfolio_date ON journal.trades(portfolio_id, trade_date);
CREATE INDEX idx_trades_instrument     ON journal.trades(instrument_key, trade_date);
SQL — journal.trade_notes (post-trade review)
-- Separate from trades for a reason: notes are written after the fact
-- A trade may have multiple notes (entry review, exit review, weekly review)

CREATE TABLE journal.trade_notes (
    note_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    trade_id        UUID NOT NULL REFERENCES journal.trades(trade_id),
    note_type       TEXT NOT NULL,    -- 'entry', 'exit', 'review', 'lesson'
    content         TEXT NOT NULL,
    emotion         TEXT,             -- 'confident', 'fearful', 'greedy', 'neutral'
    rating          INT,              -- self-score 1-5 for trade execution quality
    screenshot_url  TEXT,             -- link to chart screenshot at entry/exit
    created_at      TIMESTAMPTZ DEFAULT now()
);

Design Decision — Immutable Trades + Separate Notes

Trades are immutable; notes are append-only. Once a fill is recorded, it never changes—this mirrors how brokerages work and prevents retroactive data manipulation. Notes are separated because they’re written at different times (thesis at entry, review days later, lessons during weekly review). The emotion and rating fields enable systematic analysis of psychological patterns—the kind of self-awareness that separates profitable traders from the rest.

Position Tracking

Current positions are derived from the trades table—no separate position table to keep in sync. A view computes the running state.

SQL — journal.v_current_positions (live positions view)
CREATE VIEW journal.v_current_positions AS
WITH signed_trades AS (
    SELECT
        portfolio_id,
        instrument_key,
        trade_date,
        CASE
            WHEN side IN ('buy', 'cover') THEN  quantity
            WHEN side IN ('sell', 'short') THEN -quantity
        END AS signed_qty,
        price,
        commission
    FROM journal.trades
),
positions AS (
    SELECT
        portfolio_id,
        instrument_key,
        SUM(signed_qty) AS net_quantity,
        -- Weighted average cost (buys only)
        SUM(CASE WHEN signed_qty > 0
            THEN signed_qty * price ELSE 0 END)
        / NULLIF(SUM(CASE WHEN signed_qty > 0
            THEN signed_qty ELSE 0 END), 0) AS avg_cost,
        SUM(commission)  AS total_commissions,
        MIN(trade_date)  AS first_trade,
        MAX(trade_date)  AS last_trade
    FROM signed_trades
    GROUP BY portfolio_id, instrument_key
)
SELECT
    p.*,
    i.symbol,
    i.name,
    i.sector
FROM positions p
JOIN instruments i ON i.instrument_key = p.instrument_key
  AND i.valid_to IS NULL
WHERE p.net_quantity <> 0;  -- exclude closed positions

P&L Calculation

Realized P&L is computed at the trade level using FIFO matching. Unrealized P&L uses the latest bar close from bars_daily—all in a single query, no external dependency.

SQL — Portfolio P&L as-of any date
-- "What was my P&L on 2025-06-15?"
-- Combines realized (closed trades) + unrealized (open positions x market price)

WITH trades_asof AS (
    SELECT
        portfolio_id,
        instrument_key,
        SUM(CASE WHEN side IN ('buy','cover')
            THEN quantity ELSE -quantity END) AS net_qty,
        SUM(CASE WHEN side IN ('sell','short')
            THEN quantity * price ELSE 0 END)
        - SUM(CASE WHEN side IN ('buy','cover')
            THEN quantity * price ELSE 0 END) AS realized_pnl,
        SUM(commission) AS total_fees
    FROM journal.trades
    WHERE trade_date <= '2025-06-15'
    GROUP BY portfolio_id, instrument_key
),
market_prices AS (
    SELECT DISTINCT ON (instrument_key)
        instrument_key, close AS market_price
    FROM bars_daily
    WHERE trade_date <= '2025-06-15'
    ORDER BY instrument_key, trade_date DESC
)
SELECT
    t.portfolio_id,
    i.symbol,
    t.net_qty,
    t.realized_pnl,
    t.net_qty * m.market_price AS market_value,
    t.net_qty * m.market_price - t.net_qty *
        ABS(t.realized_pnl / NULLIF(t.net_qty, 0)) AS unrealized_pnl,
    t.total_fees,
    t.realized_pnl - t.total_fees AS net_realized
FROM trades_asof t
JOIN instruments i ON i.instrument_key = t.instrument_key
  AND i.valid_to IS NULL
LEFT JOIN market_prices m ON m.instrument_key = t.instrument_key
ORDER BY ABS(t.net_qty * m.market_price) DESC;

Analytical Queries

With the journal embedded in the securities master, you can answer questions that are impossible with a standalone spreadsheet.

SQL — Win rate by setup type with market context
-- "What's my win rate on breakout trades when RSI was below 40?"

WITH closed_trades AS (
    SELECT
        t.trade_id, t.instrument_key, t.trade_date,
        t.setup_type, t.side, t.price, t.quantity,
        LEAD(t.price) OVER (
            PARTITION BY t.instrument_key
            ORDER BY t.trade_date
        ) AS exit_price
    FROM journal.trades t
    WHERE t.side = 'buy'
),
with_context AS (
    SELECT
        ct.*,
        f.rsi_14 AS entry_rsi,
        (ct.exit_price - ct.price) / ct.price AS return_pct
    FROM closed_trades ct
    JOIN mart.fact_daily_returns f
      ON f.instrument_key = ct.instrument_key
     AND f.date_key = ct.trade_date
    WHERE ct.exit_price IS NOT NULL
)
SELECT
    setup_type,
    COUNT(*)                                              AS total_trades,
    ROUND(100.0 * COUNT(*) FILTER (
        WHERE return_pct > 0) / COUNT(*), 1)           AS win_rate_pct,
    ROUND(AVG(return_pct) * 100, 2)                       AS avg_return_pct,
    ROUND(AVG(entry_rsi), 1)                              AS avg_entry_rsi
FROM with_context
WHERE entry_rsi < 40
GROUP BY setup_type
ORDER BY win_rate_pct DESC;
SQL — Emotional pattern analysis
-- "Do I make worse trades when I'm feeling greedy?"

SELECT
    tn.emotion,
    COUNT(*)                    AS trades,
    ROUND(AVG(tn.rating), 1)   AS avg_self_rating,
    ROUND(100.0 * COUNT(*) FILTER (WHERE
        EXISTS (SELECT 1 FROM journal.trades t2
                WHERE t2.instrument_key = t.instrument_key
                  AND t2.side = 'sell'
                  AND t2.price > t.price
                  AND t2.trade_date > t.trade_date)
    ) / COUNT(*), 1)           AS win_rate_pct
FROM journal.trade_notes tn
JOIN journal.trades t ON t.trade_id = tn.trade_id
WHERE tn.note_type = 'entry'
  AND tn.emotion IS NOT NULL
GROUP BY tn.emotion
ORDER BY trades DESC;

The Integration Advantage

These queries are only possible because the journal shares a database with the securities master. When you buy AAPL, the journal records the trade with instrument_key—the same key used by bars_daily, fundamentals, and fact_daily_returns. No CSV imports, no manual ticker matching, no reconciliation. The foreign key IS the integration.

MCP & Semantic Layer — AI-Native Access

From SQL to Natural Language

A modern securities master doesn’t stop at SQL dashboards. Our stack is AI-native by design: any LLM—Claude, Gemini, Grok, DeepSeek—can query the database directly through Model Context Protocol (MCP). The key is a semantic layer that translates natural language into safe, governed SQL.

Natural Language

“What’s my portfolio drawdown since earnings?” → The LLM interprets intent, maps to semantic metrics

Semantic Layer

Boring Semantic Layer defines metrics, dimensions, and relationships via Ibis. No raw SQL exposed to the LLM

Governed Execution

Queries are compiled to SQL by Ibis (via Boring Semantic Layer), validated against the schema, and executed with read-only permissions

Structured Response

Results return as typed DataFrames. The LLM formats them as tables, charts, or narrative text

Design Decision — Why NAO + Boring Semantic Layer

NAO is an open-source analytics agent builder and AI data IDE that provides the agent framework and context management. Boring Semantic Layer is a lightweight semantic layer with native MCP support, built on Ibis, the universal dataframe API. Together, NAO provides the agent orchestration while Boring Semantic Layer provides LLM-friendly semantic metrics via its MCPSemanticModel. Why Ibis under the hood? Because it compiles to native SQL for 20+ backends (PostgreSQL, DuckDB, BigQuery, Snowflake) without code changes. This means our semantic layer works identically whether the data lives in our production Postgres or in a DuckDB export for local development. Ibis expressions are also type-safe and composable—far safer than letting an LLM generate raw SQL strings.

Architecture

graph TD A["User
'Show my sector exposure'"] -->|prompt| B["LLM
Claude / Gemini / Grok
(via OpenRouter)"] B -->|MCP Protocol
stdio / SSE| C["Boring Semantic Layer
+ NAO Agent Context
(Python)"] C -->|"list_metrics()
query(dimensions, measures)"| D["PostgreSQL
secmaster DB
mart.* / journal.*"]

Design Decision — OpenRouter as LLM Gateway

One API, all models. OpenRouter provides a unified API for Claude, Gemini, Grok, DeepSeek, Llama, and 200+ models. This means the chatbot/MCP client isn’t locked to a single provider. Swap models via a config change—use Claude for complex analysis, Gemini Flash for quick lookups, and Grok for real-time market sentiment. The semantic layer is model-agnostic; only the MCP client changes.

Semantic Layer Definition

The semantic layer is defined in Python using Boring Semantic Layer's declarative API. Each table gets typed dimensions and measures—the LLM discovers them via MCP and composes queries without writing SQL.

Python — semantic_layer.py (Boring Semantic Layer definitions)
from boring_semantic_layer import SemanticModel, Dimension, Measure
import ibis

con = ibis.postgres.connect(
    host="localhost",
    database="secmaster",
    user="secmaster",
    schema="mart",
)

model = SemanticModel(connection=con)

# Define semantic tables with dimensions and measures
model.add_table(
    "daily_returns",
    table=con.table("fact_daily_returns"),
    dimensions=[
        Dimension("sector", description="GICS sector classification"),
        Dimension("region", description="Exchange region (US, EU, APAC)"),
        Dimension("symbol", description="Ticker symbol"),
    ],
    measures=[
        Measure("avg_return", expression="daily_return.mean()", description="Average daily return"),
        Measure("total_vol", expression="rolling_vol_20d.mean()", description="Average 20d rolling volatility"),
        Measure("max_drawdown", expression="drawdown.min()", description="Maximum drawdown"),
        Measure("count", expression="instrument_key.nunique()", description="Number of instruments"),
    ],
)

MCP Server Configuration

Boring Semantic Layer's MCPSemanticModel exposes the semantic layer as MCP tools natively. Each dimension and measure becomes discoverable by LLMs.

Python — mcp_server.py (Boring Semantic Layer + MCPSemanticModel)
from boring_semantic_layer.mcp import MCPSemanticModel

mcp = MCPSemanticModel(
    model=model,
    name="secmaster",
    description="Securities Master -- multi-asset, multi-region financial database",
)

if __name__ == "__main__":
    mcp.run(transport="stdio")

Client Configuration

Any MCP-compatible client can connect: Claude Code, Claude Desktop, custom chatbots, or CI pipelines.

JSON — claude_desktop_config.json (Claude Desktop / Claude Code)
{
    "mcpServers": {
        "secmaster": {
            "command": "python",
            "args": ["mcp_server.py"],
            "env": {
                "DATABASE_URL": "postgresql://secmaster:***@localhost:5432/secmaster"
            }
        }
    }
}
Python — chatbot.py (OpenRouter multi-model chatbot)
import openai
from mcp_client import MCPClient

# OpenRouter: one API key, all models
llm = openai.OpenAI(
    base_url="https://openrouter.ai/api/v1",
    api_key="sk-or-...",
)

# Connect to our MCP server
mcp = MCPClient(command="python mcp_server.py")
tools = mcp.list_tools()  # auto-discovered from semantic layer

# Multi-model routing
MODELS = {
    "analysis":  "anthropic/claude-sonnet-4",   # deep reasoning
    "quick":     "google/gemini-2.0-flash",     # fast lookups
    "sentiment": "x-ai/grok-3",                # real-time Twitter/X
}

def query(question: str, mode: str = "analysis") -> str:
    """Ask the securities master a question in natural language."""
    response = llm.chat.completions.create(
        model=MODELS[mode],
        messages=[
            {"role": "system", "content": "You are a financial analyst. "
             "Use the secmaster tools to answer questions with data."},
            {"role": "user", "content": question},
        ],
        tools=tools,
    )
    # Handle tool calls, execute via MCP, return formatted answer
    return handle_tool_loop(response, mcp, llm)

# Example usage:
# query("What's my tech sector exposure today?")
# query("Quick: AAPL's RSI?", mode="quick")
# query("What's the Twitter sentiment on NVDA?", mode="sentiment")

Design Decision — Semantic Layer vs. Raw SQL Access

Never give an LLM raw SQL access to production. Even with read-only permissions, an LLM can generate SELECT * FROM bars_daily (60M rows) and crash your dashboard. The semantic layer constrains what the LLM can query: pre-defined metrics with row limits, typed parameters, and governed aggregations. The Ibis abstraction under Boring Semantic Layer also prevents SQL injection—parameters are bound, never interpolated. Think of it as an API for your database: the LLM calls functions, not raw SQL.

What This Enables

With MCP + the Boring Semantic Layer, you can ask your securities master questions in plain English:

  • “Show my portfolio exposure by sector as of last Friday” → calls portfolio_exposure()
  • “Which tech stocks have the worst drawdown this year?” → calls drawdown_leaders("US", -0.15)
  • “Compare healthcare vs energy sector performance monthly” → calls sector_performance() twice
  • “What was my win rate on earnings plays?” → queries the journal via a journal metric

Claude Code users get this natively: configure the MCP server in .claude/settings.json and the tools appear automatically. No UI to build, no API to deploy—the database speaks LLM.

Infrastructure

Nomad Jobs & PostgreSQL

Hardware: A $5/mo VM is enough to start

You don't need a massive AWS cluster to run this infrastructure. A basic $5/month Virtual Machine (VPS) from providers like Hetzner, DigitalOcean, or Linode with 1 vCPU and 1-2GB of RAM is perfectly sufficient to start. Nomad's orchestrator only consumes ~50MB RAM, and PostgreSQL is extremely efficient on low-end hardware when properly partitioned and indexed. You can always vertically scale the instance later as your data volume grows, but a cheap VM is the perfect starting point for learning and building.

Nomad manages two services: PostgreSQL itself and a periodic job that pre-creates partitions.

PostgreSQL as a Nomad Service

HCL — Nomad Job
job "postgres" {
    type        = "service"
    datacenters = ["dc1"]

    group "db" {
        network {
            port "pg" { static = 5432 }
        }

        volume "pgdata" {
            type   = "host"
            source = "pgdata"
        }

        task "postgres" {
            driver = "docker"

            config {
                image = "postgres:16-alpine"
                ports = ["pg"]
            }

            volume_mount {
                volume      = "pgdata"
                destination = "/var/lib/postgresql/data"
            }

            env {
                POSTGRES_DB       = "secmaster"
                POSTGRES_USER     = "secmaster"
                POSTGRES_PASSWORD = "${NOMAD_META_pg_password}"
            }

            resources {
                cpu    = 2000  # 2 cores
                memory = 4096  # 4 GB -- shared_buffers should be 1GB
            }
        }
    }
}

Automatic Partition Creation

A Go utility runs as a Nomad periodic job. It checks which partitions exist and creates any missing ones up to 3 months ahead.

Go — Partition Creator (simplified)
package main

import (
    "database/sql"
    "fmt"
    "log"
    "os"
    "time"
    _ "github.com/lib/pq"
)

func ensurePartitions(db *sql.DB, table string, months int) error {
    now := time.Now()
    for i := 0; i < months; i++ {
        start := time.Date(now.Year(), now.Month()+time.Month(i), 1, 0, 0, 0, 0, time.UTC)
        end := start.AddDate(0, 1, 0)
        name := fmt.Sprintf("%s_%s", table, start.Format("2006_01"))

        query := fmt.Sprintf(`
            CREATE TABLE IF NOT EXISTS %s
            PARTITION OF %s
            FOR VALUES FROM ('%s') TO ('%s')
        `, name, table, start.Format("2006-01-02"), end.Format("2006-01-02"))

        if _, err := db.Exec(query); err != nil {
            return fmt.Errorf("partition %s: %w", name, err)
        }
        log.Printf("ensured partition %s", name)
    }
    return nil
}

func main() {
    db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    defer db.Close()

    tables := []string{"bars_daily", "bars_intraday"}
    for _, t := range tables {
        if err := ensurePartitions(db, t, 3); err != nil {
            log.Fatal(err)
        }
    }
}
HCL — Nomad Periodic Job
job "partition-creator" {
    type        = "batch"
    datacenters = ["dc1"]

    periodic {
        cron             = "0 0 1 * *"  # 1st of every month
        prohibit_overlap = true
    }

    group "partitions" {
        task "create" {
            driver = "exec"
            config {
                command = "/usr/local/bin/partition-creator"
            }
            env {
                DATABASE_URL = "postgres://secmaster:${NOMAD_META_pg_password}@localhost:5432/secmaster"
            }
        }
    }
}
First Pipeline

Meltano + dbt: End to End

Let's wire everything together: extract daily bars from Yahoo Finance, load into PostgreSQL, transform with dbt, and verify with Evidence.dev.

Step 1: Initialize Meltano

Terminal $ meltano init secmaster && cd secmaster $ meltano add extractor tap-yahoo-finance $ meltano add loader target-postgres

Step 2: Configure the Pipeline

YAML — meltano.yml
version: 1
project_id: secmaster

plugins:
  extractors:
    - name: tap-yahoo-finance
      variant: custom
      pip_url: git+https://github.com/your-org/tap-yahoo-finance.git
      config:
        symbols:
          - AAPL
          - MSFT
          - GOOGL
          - AMZN
          - TSLA
        start_date: "2000-01-01"
        streams:
          - bars_daily
          - instrument_metadata
          - dividends
          - splits

  loaders:
    - name: target-postgres
      variant: transferwise
      config:
        host: localhost
        port: 5432
        user: secmaster
        dbname: secmaster
        default_target_schema: raw_yahoo

Step 3: Run the Extract & Load

Terminal $ meltano run tap-yahoo-finance target-postgres INFO Starting extraction: tap-yahoo-finance INFO Stream bars_daily: extracted 31,250 records (5 symbols x 6,250 trading days) INFO Stream instrument_metadata: extracted 5 records INFO Stream dividends: extracted 312 records INFO Stream splits: extracted 8 records INFO Loading to target-postgres schema raw_yahoo INFO Loaded 31,575 records in 4.2s

Step 4: Transform with dbt

SQL — dbt model: stg_bars_daily.sql
-- models/staging/stg_bars_daily.sql
-- Incremental model: only process new rows since last run

{{ config(
    materialized='incremental',
    unique_key=['instrument_key', 'trade_date'],
    partition_by={'field': 'trade_date', 'data_type': 'date', 'granularity': 'month'}
) }}

SELECT
    i.instrument_key,
    r.date::date             AS trade_date,
    r.open::numeric(14,4)    AS open,
    r.high::numeric(14,4)    AS high,
    r.low::numeric(14,4)     AS low,
    r.close::numeric(14,4)   AS close,
    r.volume::bigint         AS volume,
    r.adj_close::numeric(14,4) AS adj_close,
    'yahoo'                  AS source,
    now()                    AS loaded_at
FROM {{ source('raw_yahoo', 'bars_daily') }} r
JOIN {{ ref('stg_instruments') }} i
  ON i.symbol = r.symbol
 AND i.valid_to IS NULL
WHERE r.open > 0
  AND r.high >= r.low
  AND r.volume >= 0
{% if is_incremental() %}
  AND r._sdc_extracted_at > (
      SELECT MAX(loaded_at) FROM {{ this }}
  )
{% endif %}

Step 5: Data Quality Tests

YAML — dbt tests: schema.yml
version: 2

models:
  - name: stg_bars_daily
    description: "Cleaned daily OHLCV bars"
    columns:
      - name: instrument_key
        tests: [not_null]
      - name: trade_date
        tests: [not_null]
      - name: open
        tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: "> 0"
      - name: high
        tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: ">= low"
      - name: close
        tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: ">= low AND close <= high"
    tests:
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns:
            - instrument_key
            - trade_date
Terminal $ dbt run && dbt test Running with dbt=1.7.0 Found 12 models, 47 tests, 6 sources Completed successfully Done. PASS=47 WARN=0 ERROR=0 SKIP=0 TOTAL=47
Verification

Evidence.dev: Data Quality Dashboard

Evidence.dev lets you write SQL + Markdown to create data dashboards. We use it to monitor the health of the securities master.

Markdown — Evidence.dev Page (data-quality.md) # Data Quality Report ```sql coverage``` SELECT i.asset_class, COUNT(DISTINCT i.instrument_key) AS instruments, MIN(b.trade_date) AS earliest_bar, MAX(b.trade_date) AS latest_bar, COUNT(b.*) AS total_bars, ROUND(AVG( (b.trade_date - LAG(b.trade_date) OVER( PARTITION BY b.instrument_key ORDER BY b.trade_date ))::int ), 1) AS avg_gap_days FROM instruments i LEFT JOIN bars_daily b ON b.instrument_key = i.instrument_key WHERE i.valid_to IS NULL GROUP BY i.asset_class ORDER BY instruments DESC; <DataTable data={coverage} /> ```sql gaps``` SELECT instrument_key, trade_date, trade_date - LAG(trade_date) OVER( PARTITION BY instrument_key ORDER BY trade_date ) AS gap_days FROM bars_daily HAVING gap_days > 5 -- flag gaps > 1 trading week ORDER BY gap_days DESC LIMIT 20; <BarChart data={gaps} x=instrument_key y=gap_days />
Terminal $ cd evidence-project $ npm run dev Evidence dev server running at http://localhost:3000 Connected to PostgreSQL: secmaster@localhost:5432/secmaster

What to Monitor

A healthy securities master should have:

  • No gaps > 5 days in bar data (except weekends/holidays)
  • Latest bar date = yesterday (or last trading day)
  • Zero NULL prices in bars_daily
  • All instruments have an exchange (no orphans)
  • Fundamentals coverage ≥ 80% of active equities
  • Partition exists for the next 3 months
Takeaways & Quiz

Key Takeaways

  • Survivorship bias inflates returns by ~2% per year. Your securities master must track delistings as events, never delete data.
  • SCD Type 2 enables as-of queries. The pattern valid_from <= @date AND (valid_to > @date OR valid_to IS NULL) gives you a perfect snapshot at any point in time.
  • Point-in-time fundamentals prevent look-ahead bias. Use reported_at/superseded_at to track when data was actually known to the market.
  • PostgreSQL beats ClickHouse for SCD workloads. You need UPDATE operations and ACID transactions for closing old SCD rows. ClickHouse's immutable storage makes this painful.
  • Monthly partitioning is the sweet spot. ~200K rows per partition fits in memory, and ~300 partitions for 25 years keeps the catalog manageable.
  • ELT over ETL. Load raw data first, transform later. When a dbt model has a bug, fix the SQL and re-run. The raw data is always there.
  • Star schema turns ad-hoc questions into composable SQL. Fact tables (returns, portfolio snapshots) join to dimensions (date, instrument, sector) in a predictable pattern. Pre-compute returns and drawdowns in the dbt build; analysts get sub-second queries.
  • Embed the trading journal in the securities master. Same database, different schema. Every trade inherits instrument metadata and price context via foreign keys—no CSV imports or reconciliation needed.
  • MCP + semantic layer makes the database AI-native. Boring Semantic Layer (built on Ibis) gives LLMs governed, type-safe access to the data via MCP. Never expose raw SQL to an LLM—use a semantic layer with row limits and typed parameters.

Quiz

What is the primary purpose of SCD Type 2 in a securities master?
To preserve the complete history of metadata changes. Instead of overwriting a record when data changes (e.g., sector reclassification), SCD Type 2 closes the old version (sets valid_to) and creates a new one. This allows you to query the state of any entity at any point in time.
Why do we use reported_at / superseded_at instead of valid_from / valid_to for fundamentals?
Fundamentals are period-based (Q3 2025 EPS), not entity-based. The same metric for the same period can have multiple values over time (original report, then restatement). reported_at tracks when each value became known, and superseded_at tracks when it was replaced. This prevents look-ahead bias—you only see data that was actually available at your query date.
Why monthly partitioning instead of daily or yearly?
Daily creates too many partitions (~6,500 for 25 years), which bloats the PostgreSQL catalog and increases query planning time. Yearly partitions are too large (~2.5M rows for 10K instruments), making constraint exclusion slower. Monthly is the sweet spot: ~200K rows per partition, ~300 total partitions, each fitting comfortably in RAM.
What is survivorship bias and how does the securities master prevent it?
Survivorship bias occurs when delisted companies (bankruptcies, acquisitions, etc.) disappear from datasets, leaving only survivors. This inflates backtested returns by ~2% per year. The securities master prevents it by never deleting instruments. Delistings are recorded as corporate actions with is_active = false, and index_constituents tracks exactly when each security was added/removed.
Why use NUMERIC(14,4) instead of DOUBLE PRECISION for prices?
Floating-point arithmetic has rounding errors: 0.1 + 0.2 = 0.30000000000000004 in IEEE 754. For financial calculations, exact decimal arithmetic is required. NUMERIC stores values as exact decimal, so $100.25 is stored as exactly 100.2500, with no representation error. The 14-digit precision supports prices up to ~$10 billion with 4 decimal places.
What is the universal as-of query pattern?
WHERE valid_from <= @as_of_date AND (valid_to > @as_of_date OR valid_to IS NULL). This pattern works identically for instruments, index_constituents, and any SCD Type 2 table. For fundamentals, the equivalent is WHERE reported_at <= @as_of_date AND (superseded_at > @as_of_date OR superseded_at IS NULL). The IS NULL clause handles currently-valid records.
Why use a star schema instead of querying the OLTP tables directly for BI?
The OLTP tables are normalized and optimized for writes (SCD Type 2 updates, partitioned inserts). BI queries require joins across 3–5 tables and window functions on millions of rows, which is slow. The star schema pre-computes denormalized dimensions and pre-aggregated facts (returns, drawdown, rolling vol) so every analytical query is a simple fact JOIN dim WHERE ... GROUP BY. The dbt build pays the computation cost once; analysts get sub-second responses.
Why are trades immutable and notes stored in a separate table?
Trades mirror brokerage fills—once recorded, they never change. This prevents retroactive manipulation and ensures P&L calculations are auditable. Notes are separate because they’re written at different times: entry thesis at the moment of trading, exit review days later, weekly lessons even later. The emotion and rating columns enable systematic analysis of psychological patterns (e.g., “Do I lose money when I feel greedy?”).
What is the role of the semantic layer (Boring Semantic Layer + NAO) in the MCP architecture?
The semantic layer sits between LLMs and the database. Instead of letting an LLM generate raw SQL (which can produce dangerous queries like SELECT * FROM bars_daily on 60M rows), the Boring Semantic Layer defines type-safe, composable dimensions and measures with built-in row limits and parameter validation. Under the hood it uses Ibis, which compiles to native SQL for any backend (PostgreSQL, DuckDB, BigQuery), making the layer backend-agnostic. NAO provides the agent framework and context management. The LLM discovers metrics via MCP's MCPSemanticModel; it never writes SQL directly.
Coming Soon — Part 2
Pipelines — Meltano, Custom Taps & Incremental Extraction

DailyTickers — Building a Securities Master series — Part 1 of 6

This article is for educational purposes only. It does not constitute investment advice.

Securities Master1/6
The Single Source of…Point-in-Time &…End-to-End Data FlowWhy This Stack?Structured Trading J…From SQL to Natural…Nomad Jobs & Pos…Evidence.dev: Data Q…Quiz