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.
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.
Delisted companies vanish from datasets, inflating backtested returns by 2–4% annually
Restated financials silently overwrite originals. Your backtest uses data that didn't exist yet
CSV files, API caches, spreadsheets, ad-hoc scripts. No versioning, no lineage, no tests
AAPL on Yahoo, US0378331005 on Bloomberg, 037833100 CUSIP. Same security, different keys everywhere
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.
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.
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.
-- 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
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.
-- 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
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.
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.
-- 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)
-- 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
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%.
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.
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.
Yahoo Finance, SEC EDGAR, NASDAQ listings, exchanges reference data, corporate actions feeds
Meltano orchestrates Singer taps. Raw JSON/CSV lands in PostgreSQL raw_* schemas unchanged
dbt models clean, deduplicate, apply SCD2 logic, partition by date, and run 200+ tests
PostgreSQL mart schema with materialized views. Sub-10ms as-of queries via partial indexes
Evidence.dev dashboards for data quality monitoring, coverage reports, and anomaly detection
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.
Every component was chosen after evaluating alternatives. Here's the reasoning.
| 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 |
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.
| 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 |
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).
| 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 |
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.
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.
The data model has six core tables. Each one serves a specific purpose and has specific design decisions behind it.
exchanges — Reference DataThe anchor table. Every instrument belongs to an exchange. This is the simplest table because exchange metadata rarely changes.
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'
);
SMALLINT primary keyThere 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.
instruments — SCD Type 2The 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.
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;
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.
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.
accounts & strategies — Trading ArchitectureA 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.
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
);
ExecutionReport — The OMS JournalFinally, 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.
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
);
bars_daily — Partitioned Time SeriesThe 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.
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');
NUMERIC(14,4) for PricesFLOAT/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+).
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.
fundamentals — Point-in-TimeEarnings, 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).
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);
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.
corporate_actions — Event SourcingSplits, dividends, mergers, delistings. Each action is an immutable event with a specific effective date.
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);
JSONB for Action DetailsCorporate 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.
index_constituents — Composition HistoryWhich securities were in which index on which date. Critical for avoiding survivorship bias in index-relative backtests.
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
Every temporal table in the system follows the same pattern. You can query any data as of any date with this 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);
Here's the estimated row count for a multi-region universe of ~10,000 instruments with 25 years of history.
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.
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.
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.
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.
-- 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
-- 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
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 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.
-- 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
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.
-- 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?"
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.
With the star schema in place, complex analytical questions become simple SQL.
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;
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;
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;
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.
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.
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.
-- 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()
);
-- 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);
-- 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()
);
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.
Current positions are derived from the trades table—no separate position table to keep in sync. A view computes the running state.
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
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.
-- "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;
With the journal embedded in the securities master, you can answer questions that are impossible with a standalone spreadsheet.
-- "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;
-- "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;
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.
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.
“What’s my portfolio drawdown since earnings?” → The LLM interprets intent, maps to semantic metrics
Boring Semantic Layer defines metrics, dimensions, and relationships via Ibis. No raw SQL exposed to the LLM
Queries are compiled to SQL by Ibis (via Boring Semantic Layer), validated against the schema, and executed with read-only permissions
Results return as typed DataFrames. The LLM formats them as tables, charts, or narrative text
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.
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.
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.
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"),
],
)
Boring Semantic Layer's MCPSemanticModel exposes the semantic layer as MCP tools natively. Each
dimension and measure becomes discoverable by LLMs.
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")
Any MCP-compatible client can connect: Claude Code, Claude Desktop, custom chatbots, or CI pipelines.
{
"mcpServers": {
"secmaster": {
"command": "python",
"args": ["mcp_server.py"],
"env": {
"DATABASE_URL": "postgresql://secmaster:***@localhost:5432/secmaster"
}
}
}
}
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")
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.
With MCP + the Boring Semantic Layer, you can ask your securities master questions in plain English:
portfolio_exposure()drawdown_leaders("US", -0.15)sector_performance() twiceClaude 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.
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.
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
}
}
}
}
A Go utility runs as a Nomad periodic job. It checks which partitions exist and creates any missing ones up to 3 months ahead.
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)
}
}
}
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"
}
}
}
}
Let's wire everything together: extract daily bars from Yahoo Finance, load into PostgreSQL, transform with dbt, and verify with Evidence.dev.
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
-- 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 %}
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
Evidence.dev lets you write SQL + Markdown to create data dashboards. We use it to monitor the health of the securities master.
A healthy securities master should have:
valid_from <= @date AND (valid_to > @date OR valid_to IS NULL) gives you a perfect
snapshot at any point in time.
reported_at/superseded_at to track when data was actually
known to the market.valid_to) and creates a new one. This allows you to query the state of any entity at any point in
time.
reported_at / superseded_at instead of valid_from / valid_to
for fundamentals?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.is_active = false, and index_constituents tracks exactly when each security was added/removed.
NUMERIC(14,4) instead of DOUBLE PRECISION for prices?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.
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.
fact JOIN dim WHERE ... GROUP BY.
The dbt build pays the computation cost once; analysts get sub-second responses.emotion and rating columns enable systematic analysis
of psychological patterns (e.g., “Do I lose money when I feel greedy?”).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.