Python cannot ingest 100,000 tickers concurrently without hitting the GIL wall. Learn to write custom Go Singer taps, package them inside Meltano, and orchestrate nightly pipelines with HashiCorp Nomad — from raw API chaos to a validated PostgreSQL raw zone in under 10 minutes.
In Part 1 we designed a securities master covering ~30,000 instruments across equities, ETFs, futures, and crypto. Ingesting that universe daily is a throughput problem—and the naive Python approach fails spectacularly.
Consider a standard Python ELT loop calling the Yahoo Finance API for each ticker:
import requests
symbols = load_universe() # 10,000 symbols
for sym in symbols:
resp = requests.get(f"https://query2.finance.yahoo.com/v8/finance/chart/{sym}")
store(resp.json()) # ~2s per symbol (network round-trip + parsing)
# Total: 10,000 × 2s = 20,000s ≈ 5.5 hours
# The market opens at 09:30. Your data is stale by noon.
The "obvious" fix is asyncio. But Python's Global Interpreter Lock (GIL) means only one thread
of Python bytecode can run at a time. asyncio releases the GIL during IO, so concurrent
coroutines help—but it requires every library in your call chain to be async-native. Most
financial libraries (requests, yfinance, psycopg2, TA-Lib) use blocking C extensions that cannot yield to the
event loop. You end up either blocking the entire event loop or spawning heavyweight OS processes.
Go solves this at the language level. Goroutines are multiplexed onto OS threads by the Go runtime scheduler. There is no GIL. A Go goroutine starts at ~2 KB of stack; 50,000 concurrent goroutines cost ~100 MB of RAM. The equivalent in Python threads would require ~400 GB.
Only one thread runs Python bytecode at a time. True IO parallelism requires multiprocessing (8 MB/process).
Start at 2 KB. Runtime schedules 50,000 of them. No GIL. Channels for safe cross-goroutine communication.
JSON to stdout. Language-agnostic contract. Meltano pipes it to PostgreSQL. Go writes, Python loads.
One Docker container per allocation. Isolated retries. No Airflow overhead. Native periodic cron semantics.
You can get reasonable throughput for pure HTTP with async Python. But the moment you add custom CSV parsing
(BVC format), binary WebSocket streams (Binance), or exception handling that doesn't leak coroutines, the
complexity becomes unmanageable. Go's type system, sync.WaitGroup, and buffered channels make
concurrent pipelines trivial to reason about. The resulting 8 MB static binary starts in milliseconds—ideal
for Nomad ephemeral batch jobs where startup time is billed against your SLO.
The ELT philosophy is deliberately humble: never transform data you haven't persisted first. If a dbt
model has a bug, fix the SQL and re-run—the raw data is still there. In classic ETL, a transform bug before
loading means re-hitting the API. The raw zone in PostgreSQL is optimized for one thing: fast bulk
inserts via COPY. No foreign keys, no complex triggers. Just land the data.
CREATE SCHEMA raw_zone;
-- ─── Reference data: instrument metadata from any provider ────────────────────
CREATE TABLE raw_zone.instruments (
id BIGSERIAL PRIMARY KEY,
provider_name VARCHAR(50) NOT NULL, -- 'YAHOO', 'BVC', 'STOCKANALYSIS', 'BINANCE'
external_id VARCHAR(100), -- provider-native ID (BVC Drupal node, Yahoo ticker)
symbol VARCHAR(50),
isin VARCHAR(12),
instrument_type VARCHAR(50), -- 'EQUITY', 'CRYPTO', 'ETF', 'FUTURE'
raw_payload JSONB NOT NULL, -- exact API response, verbatim
ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Idempotency: re-running the tap on the same day is a no-op
CONSTRAINT uq_raw_instrument UNIQUE (provider_name, external_id, ingested_at::date)
);
CREATE INDEX idx_raw_instr_provider ON raw_zone.instruments (provider_name, ingested_at DESC);
CREATE INDEX idx_raw_instr_isin ON raw_zone.instruments (isin) WHERE isin IS NOT NULL;
-- ─── OHLCV time-series — partitioned by year for parallel COPY ────────────────
CREATE TABLE raw_zone.market_data (
provider_name VARCHAR(50) NOT NULL,
symbol VARCHAR(50) NOT NULL,
trade_date DATE NOT NULL, -- DATE not TIMESTAMP: avoids TZ drift bugs
open_price NUMERIC(20,8),
high_price NUMERIC(20,8),
low_price NUMERIC(20,8),
close_price NUMERIC(20,8),
adj_close NUMERIC(20,8),
volume NUMERIC(20,8),
raw_payload JSONB, -- full JSON kept for debugging
ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (provider_name, symbol, trade_date)
) PARTITION BY RANGE (trade_date);
-- Year partitions (Go partition manager auto-creates future ones — see Part 1)
CREATE TABLE raw_zone.market_data_y2000 PARTITION OF raw_zone.market_data
FOR VALUES FROM ('2000-01-01') TO ('2001-01-01');
-- ... remaining year partitions created by partition_manager.go
-- ─── Watermarks: Singer STATE persisted per (provider, stream) ─────────────────
-- Meltano writes these after each successful run; the Go tap reads them on startup
CREATE TABLE raw_zone.watermarks (
provider_name VARCHAR(50) NOT NULL,
stream_name VARCHAR(100) NOT NULL,
bookmark_value TEXT NOT NULL, -- ISO-8601 date or opaque cursor token
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (provider_name, stream_name)
);
DATE not TIMESTAMPTZ for trade_date?Providers disagree on timezone representation. Yahoo Finance returns Unix timestamps in UTC; Casablanca
Bourse returns bare date strings ("2024-01-10") with no timezone. Storing as
TIMESTAMPTZ requires a consistent timezone assumption at ingestion time—but there is none.
A Unix timestamp of 00:00 UTC converts to the previous calendar day in US/Eastern, creating phantom
duplicates. A bare DATE forces normalization in the Go tap: convert to UTC date, strip time.
The semantic is unambiguous: the exchange was open on this calendar day.
raw_payload JSONB alongside parsed columns?The raw_payload is an operational insurance policy. When Yahoo Finance silently renames a field—and it
has, multiple times (adjclose → adjClose in 2023)—parsed columns become
NULL but raw_payload still has the data intact. A one-line fix to a dbt bronze model is all
that's needed; no re-extraction required. Never throw away data you've already paid the network cost to fetch.
Singer is an open specification originally developed by Stitch Data, now maintained by Meltano. It defines how
a tap (extractor) communicates with a target (loader). The entire protocol is
three types of newline-delimited JSON messages on stdout—no HTTP, no gRPC, no shared memory.
Any language that can print JSON can implement a Singer tap.
This simplicity is the key: our Go binary writes Singer to stdout; Meltano pipes it to
target-postgres, which bulk-COPYs rows into PostgreSQL. The two processes share zero code.
The STATE message is the key to incremental extraction. Meltano persists the bookmark atomically after each successful run. On the next invocation, the Go tap reads it: "last time I fetched through 2026-03-07, so today I only need 2026-03-08 forward." Crash-safe: if the process dies before emitting STATE, the next run starts from the previous bookmark—preventing data gaps by design.
Singer forces a clean interface boundary. The Go tap knows nothing about PostgreSQL. The target knows nothing
about Yahoo Finance. You can swap target-postgres for target-bigquery or
target-snowflake without touching a line of Go. This plugin architecture means your ingestion
investment is reusable across all future infrastructure migrations.
tap-marketdata — Full ImplementationThe tap is a single Go binary. It reads state from a file injected by Meltano, emits Singer messages to stdout, and writes an updated STATE at the end. The architecture cleanly separates concerns: Singer emission logic, extractor implementations, and state management are in separate packages.
package singer
import (
"encoding/json"
"fmt"
"os"
"sync"
)
// mu serializes all writes to stdout.
// Multiple goroutines produce records concurrently;
// stdout is a single sequential stream that must not be interleaved.
var mu sync.Mutex
type schemaMsg struct {
Type string `json:"type"`
Stream string `json:"stream"`
Schema map[string]any `json:"schema"`
KeyProperties []string `json:"key_properties"`
}
type recordMsg struct {
Type string `json:"type"`
Stream string `json:"stream"`
Record any `json:"record"`
}
type stateMsg struct {
Type string `json:"type"`
Value any `json:"value"`
}
// EmitSchema writes a SCHEMA message. Call once per stream before any records.
func EmitSchema(stream string, schema map[string]any, keyProps []string) error {
mu.Lock()
defer mu.Unlock()
msg := schemaMsg{Type: "SCHEMA", Stream: stream, Schema: schema, KeyProperties: keyProps}
b, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal schema %s: %w", stream, err)
}
_, err = fmt.Fprintln(os.Stdout, string(b))
return err
}
// EmitRecord writes a RECORD message. Thread-safe: safe to call from goroutines.
func EmitRecord(stream string, record any) error {
mu.Lock()
defer mu.Unlock()
msg := recordMsg{Type: "RECORD", Stream: stream, Record: record}
b, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal record: %w", err)
}
_, err = fmt.Fprintln(os.Stdout, string(b))
return err
}
// EmitState writes a STATE message at end of run.
// Meltano persists this value; the next run receives it via --state flag.
func EmitState(state any) error {
mu.Lock()
defer mu.Unlock()
msg := stateMsg{Type: "STATE", Value: state}
b, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal state: %w", err)
}
_, err = fmt.Fprintln(os.Stdout, string(b))
return err
}
package yahoo
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
"tap-marketdata/internal/singer"
)
// OHLCVRecord is the canonical row emitted for every OHLCV bar.
type OHLCVRecord struct {
ProviderName string `json:"provider_name"`
Symbol string `json:"symbol"`
TradeDate string `json:"trade_date"` // "YYYY-MM-DD"
OpenPrice float64 `json:"open_price"`
HighPrice float64 `json:"high_price"`
LowPrice float64 `json:"low_price"`
ClosePrice float64 `json:"close_price"`
AdjClose float64 `json:"adj_close"`
Volume float64 `json:"volume"`
}
// Extractor fetches daily OHLCV from Yahoo Finance v8 chart API.
type Extractor struct {
client *http.Client
concurrency int
fromDate string // watermark start date "YYYY-MM-DD"
}
func New(fromDate string, concurrency int) *Extractor {
return &Extractor{
client: &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
MaxIdleConnsPerHost: concurrency,
IdleConnTimeout: 90 * time.Second,
},
},
concurrency: concurrency,
fromDate: fromDate,
}
}
// Run fetches all symbols concurrently within a semaphore-bounded goroutine pool.
// Returns the latest trade_date successfully fetched (used to update STATE).
func (e *Extractor) Run(ctx context.Context, symbols []string) (string, error) {
sem := make(chan struct{}, e.concurrency)
var wg sync.WaitGroup
var mu sync.Mutex
var errs []error
latestDate := e.fromDate
for _, sym := range symbols {
wg.Add(1)
go func(ticker string) {
defer wg.Done()
// Acquire semaphore slot — blocks when pool is full
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return // Context cancelled — bail out cleanly
}
date, err := e.fetchOne(ticker)
mu.Lock()
defer mu.Unlock()
if err != nil {
// Non-fatal: delisted symbols, holidays, API blips are expected
slog.Warn("yahoo fetch failed", "symbol", ticker, "err", err)
errs = append(errs, err)
return
}
if date > latestDate {
latestDate = date
}
}(sym)
}
wg.Wait()
if len(errs) > 0 {
// Partial failures are normal — log count and continue
slog.Warn("partial fetch errors", "count", len(errs), "total", len(symbols))
}
return latestDate, nil
}
// yahooChartResponse mirrors the JSON structure of Yahoo Finance v8 chart API.
type yahooChartResponse struct {
Chart struct {
Result []struct {
Timestamp []int64 `json:"timestamp"`
Indicators struct {
Quote []struct {
Open []float64 `json:"open"`
High []float64 `json:"high"`
Low []float64 `json:"low"`
Close []float64 `json:"close"`
Volume []float64 `json:"volume"`
} `json:"quote"`
Adjclose []struct {
Adjclose []float64 `json:"adjclose"`
} `json:"adjclose"`
} `json:"indicators"`
} `json:"result"`
Error *struct {
Code string `json:"code"`
Description string `json:"description"`
} `json:"error"`
} `json:"chart"`
}
func (e *Extractor) fetchOne(ticker string) (latestDate string, err error) {
// Fetch 10 days to cover the 3-day lookback + current week
url := fmt.Sprintf(
"https://query2.finance.yahoo.com/v8/finance/chart/%s?range=10d&interval=1d&includeAdjustedClose=true",
ticker,
)
req, _ := http.NewRequest(http.MethodGet, url, nil)
// Yahoo rate-limits automated bots — a realistic User-Agent reduces 429 errors
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
req.Header.Set("Accept", "application/json")
resp, err := e.client.Do(req)
if err != nil {
return "", fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(5 * time.Second) // back off before returning error (caller may retry)
return "", fmt.Errorf("rate limited: %s", ticker)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, ticker)
}
var result yahooChartResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode %s: %w", ticker, err)
}
if result.Chart.Error != nil {
// Yahoo returns HTTP 200 with an error body for unknown tickers
return "", fmt.Errorf("yahoo error %s: %s", result.Chart.Error.Code, result.Chart.Error.Description)
}
if len(result.Chart.Result) == 0 || len(result.Chart.Result[0].Indicators.Quote) == 0 {
return "", nil // Delisted or no data — skip silently
}
r := result.Chart.Result[0]
q := r.Indicators.Quote[0]
for i, ts := range r.Timestamp {
if i >= len(q.Close) || q.Close[i] == 0 {
continue // Skip empty bars (market closures, suspended instruments)
}
date := time.Unix(ts, 0).UTC().Format("2006-01-02")
if date < e.fromDate {
continue // Before watermark: already ingested
}
adjClose := 0.0
if len(r.Indicators.Adjclose) > 0 && i < len(r.Indicators.Adjclose[0].Adjclose) {
adjClose = r.Indicators.Adjclose[0].Adjclose[i]
}
if err := singer.EmitRecord("raw_market_data", OHLCVRecord{
ProviderName: "YAHOO", Symbol: ticker, TradeDate: date,
OpenPrice: q.Open[i], HighPrice: q.High[i], LowPrice: q.Low[i],
ClosePrice: q.Close[i], AdjClose: adjClose, Volume: q.Volume[i],
}); err != nil {
return "", err
}
if date > latestDate {
latestDate = date
}
}
return latestDate, nil
}
package bvc
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"tap-marketdata/internal/singer"
)
// InstrumentRecord is the canonical row emitted for reference data streams.
type InstrumentRecord struct {
ProviderName string `json:"provider_name"`
ExternalID string `json:"external_id"`
Symbol string `json:"symbol"`
ISIN string `json:"isin"`
Name string `json:"name"`
InstrumentType string `json:"instrument_type"`
Currency string `json:"currency"`
RawPayload any `json:"raw_payload"` // stored verbatim as JSONB
}
// Extractor fetches the complete Casablanca Bourse instrument listing.
// BVC has strict rate limits: max ~240 req/hour. Pagination via JSON:API.
type Extractor struct {
client *http.Client
baseURL string
delay time.Duration
}
func New() *Extractor {
return &Extractor{
client: &http.Client{Timeout: 30 * time.Second},
baseURL: "https://api.casablanca-bourse.com/fr/api/bourse_data/instrument?page[limit]=200",
delay: 250 * time.Millisecond,
}
}
type bvcPage struct {
Data []struct {
ID string `json:"id"`
Attributes struct {
CodeISIN string `json:"codeISIN"`
Symbol string `json:"symbol"`
Libelle string `json:"libelle"` // French security name
Secteur string `json:"secteur"`
} `json:"attributes"`
} `json:"data"`
Links struct {
Next *struct{ Href string `json:"href"` } `json:"next"`
} `json:"links"`
}
// Run paginates through the full BVC listing, emitting one RECORD per instrument.
// BVC lists ~75 companies, so sequential pagination (no concurrency needed) suffices.
func (b *Extractor) Run() error {
nextURL := b.baseURL
for page := 1; nextURL != ""; page++ {
slog.Info("fetching BVC page", "page", page)
resp, err := b.client.Get(nextURL)
if err != nil {
return fmt.Errorf("BVC page %d: %w", page, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("BVC HTTP %d on page %d", resp.StatusCode, page)
}
var result bvcPage
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("BVC parse page %d: %w", page, err)
}
for _, item := range result.Data {
attr := item.Attributes
if err := singer.EmitRecord("raw_instruments", InstrumentRecord{
ProviderName: "BVC",
ExternalID: item.ID,
Symbol: attr.Symbol,
ISIN: attr.CodeISIN,
Name: attr.Libelle,
InstrumentType: "EQUITY",
Currency: "MAD",
RawPayload: attr, // full attributes blob → stored as JSONB
}); err != nil {
return err
}
}
if result.Links.Next != nil && result.Links.Next.Href != "" {
nextURL = result.Links.Next.Href
time.Sleep(b.delay) // respect BVC rate limit strictly
} else {
nextURL = ""
}
}
return nil
}
package main
import (
"context"
"encoding/json"
"flag"
"log/slog"
"os"
"strings"
"time"
"tap-marketdata/internal/bvc"
"tap-marketdata/internal/singer"
"tap-marketdata/internal/yahoo"
)
// tapState is the Singer STATE value persisted between runs.
type tapState struct {
Bookmarks map[string]bookmark `json:"bookmarks"`
}
type bookmark struct {
LastFetchedDate string `json:"last_fetched_date"`
}
func main() {
stateFile := flag.String("state", "", "Path to Singer state file from previous run")
allocation := flag.String("allocation", "us_stocks", "us_stocks|eu_stocks|crypto|bvc")
flag.Parse()
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
// Load prior state; bookmarks are empty on the very first run
state := tapState{Bookmarks: make(map[string]bookmark)}
if *stateFile != "" {
if data, err := os.ReadFile(*stateFile); err == nil {
_ = json.Unmarshal(data, &state)
}
}
// Determine watermark; subtract 3 days for late volume/adjclose corrections
fromDate := "2000-01-01" // default: full history on first run
if bk, ok := state.Bookmarks["raw_market_data"]; ok && bk.LastFetchedDate != "" {
t, err := time.Parse("2006-01-02", bk.LastFetchedDate)
if err == nil {
fromDate = t.AddDate(0, 0, -3).Format("2006-01-02")
}
}
emitSchemas() // Declare all stream schemas once before any RECORD messages
ctx := context.Background()
var latestDate string
switch strings.ToLower(*allocation) {
case "bvc":
// BVC is small (~75 instruments) — instruments only, no OHLCV tap yet
if err := bvc.New().Run(); err != nil {
slog.Error("BVC extraction failed", "err", err)
os.Exit(1)
}
default: // us_stocks, eu_stocks, crypto, global_etfs
concurrency := 50
if *allocation == "crypto" {
concurrency = 30 // Binance/Yahoo crypto endpoints are stricter on rate limits
}
symbols := loadSymbolList(*allocation)
ext := yahoo.New(fromDate, concurrency)
var err error
if latestDate, err = ext.Run(ctx, symbols); err != nil {
slog.Error("Yahoo extraction failed", "allocation", *allocation, "err", err)
os.Exit(1)
}
}
// Update and persist the bookmark for the next incremental run
if latestDate != "" {
bk := state.Bookmarks["raw_market_data"]
bk.LastFetchedDate = latestDate
state.Bookmarks["raw_market_data"] = bk
if err := singer.EmitState(state); err != nil {
slog.Error("emit state failed", "err", err)
}
}
slog.Info("tap complete", "allocation", *allocation, "latest_date", latestDate)
}
func loadSymbolList(allocation string) []string {
// In production: read from a flat file populated nightly by the screener
// Alternative: query the instruments table directly
data, err := os.ReadFile("/etc/tap-marketdata/symbols_" + allocation + ".txt")
if err != nil {
slog.Warn("symbol list not found", "allocation", allocation, "err", err)
return nil
}
return strings.Fields(string(data))
}
meltano.yml — The ELT ManifestMeltano manages plugin discovery, state persistence, environment variable injection, and the EL execution
pipeline. Our Go binary becomes a first-class Meltano custom tap by pointing to the compiled binary
in the executable field.
version: 1
default_environment: production
plugins:
extractors:
- name: tap-marketdata-go
namespace: tap_marketdata
executable: /usr/local/bin/tap-marketdata-go # Our compiled Go binary
capabilities:
- state # Supports --state flag (incremental runs)
- catalog # Supports --catalog flag (schema declaration)
settings:
- name: allocation
kind: string
env: ALLOCATION_TARGET
- name: concurrency
kind: integer
value: 50
env: TAP_CONCURRENCY
loaders:
- name: target-postgres
variant: transferwise
pip_url: pipelinewise-target-postgres
config:
host: ${POSTGRES_HOST}
port: 5432
dbname: ${POSTGRES_DB}
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
default_target_schema: raw_zone
batch_size_rows: 10000 # Bulk COPY every 10K records
flush_all_streams: true
add_metadata_columns: true # Adds _sdc_extracted_at, _sdc_received_at
schedules:
- name: daily-us-stocks
extractor: tap-marketdata-go
loader: target-postgres
transform: skip # dbt runs as a separate Nomad job — see Part 3
interval: "@daily"
env:
ALLOCATION_TARGET: us_stocks
- name: daily-crypto
extractor: tap-marketdata-go
loader: target-postgres
transform: skip
interval: "@daily"
env:
ALLOCATION_TARGET: crypto
TAP_CONCURRENCY: "30"
transform: skip — why not run dbt inline?When transform: run is set, Meltano runs dbt immediately after extraction in the same process.
If a dbt model fails (a bug, a schema change), Meltano marks the entire schedule as failed—
preventing the Singer STATE bookmark from being saved. The next run then re-fetches already-ingested data.
With transform: skip, the EL job saves its bookmark regardless of dbt. Both jobs get
independent retry policies, independent logs, and independent failure domains.
# ─── Stage 1: Compile Go binary ───────────────────────────────────────────────
FROM golang:1.22-alpine AS go-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0: fully static binary — runs on any Linux image, no libc dependency
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o tap-marketdata-go ./cmd/tap
# ─── Stage 2: Meltano runtime (Python + target-postgres) ──────────────────────
FROM meltano/meltano:v3.3.0-python3.11
WORKDIR /project
RUN meltano add loader target-postgres --variant transferwise
COPY --from=go-builder /app/tap-marketdata-go /usr/local/bin/
RUN chmod +x /usr/local/bin/tap-marketdata-go
COPY meltano.yml .
COPY config/symbols/ /etc/tap-marketdata/
RUN meltano install # Creates Python venv for target-postgres
ENV ALLOCATION_TARGET=us_stocks
ENTRYPOINT ["meltano", "run"]
CMD ["tap-marketdata-go", "target-postgres"]
Nomad's periodic stanza turns any batch job into a cron-like schedule. Each allocation runs in
complete isolation: its own Docker container, its own CPU/memory envelope, its own log stream. A BVC
pagination error never touches the US stocks job.
variable "allocation" {
type = string
default = "us_stocks"
}
variable "tap_image" {
type = string
default = "registry.internal.org/data/tap-marketdata:latest"
}
job "marketdata-ingest-${var.allocation}" {
datacenters = ["dc1"]
type = "batch"
# Mon–Fri at 23:00 UTC (after US and EU market close)
periodic {
crons = ["0 23 * * 1-5"]
prohibit_overlap = true # Never start a second instance while one is running
timezone = "UTC"
}
reschedule {
attempts = 3
interval = "1h"
delay = "5m"
delay_function = "exponential"
max_delay = "30m"
unlimited = false
}
group "elt" {
count = 1
task "meltano-tap" {
driver = "docker"
config {
image = var.tap_image
command = "meltano"
args = ["run", "tap-marketdata-go", "target-postgres"]
}
resources {
cpu = 500 # MHz — Go tap <100 MHz; Meltano Python overhead ~400 MHz
memory = 512 # MB — Python ~256 MB; 50 goroutines ~100 MB
}
# Nomad injects secrets from Vault — no plaintext credentials in job specs
template {
data = <<EOH
{{ with secret "secret/data/dailytickers/postgres" }}
POSTGRES_HOST="{{ .Data.data.host }}"
POSTGRES_DB="{{ .Data.data.dbname }}"
POSTGRES_USER="{{ .Data.data.user }}"
POSTGRES_PASSWORD="{{ .Data.data.password }}"
{{ end }}
EOH
destination = "secrets/db.env"
env = true
}
env {
ALLOCATION_TARGET = var.allocation
TAP_CONCURRENCY = "50"
}
logs {
max_files = 7
max_file_size = 50 # MB
}
}
}
}
prohibit_overlap = true — why it's criticalWithout this, if the nightly job runs longer than 24 hours (possible during a multi-week backfill after an
outage), Nomad launches a second instance while the first is running. Two concurrent instances: (1) compete for
Yahoo Finance rate limits, increasing 429 errors; (2) attempt simultaneous COPY operations into the same
partitions, causing PRIMARY KEY conflicts and PostgreSQL lock contention; (3) race on the Singer STATE
bookmark. prohibit_overlap skips the cron tick if the previous instance is still active.
IMAGE="registry.internal.org/data/tap-marketdata:$(git rev-parse --short HEAD)"
docker build -t "$IMAGE" . && docker push "$IMAGE"
for alloc in us_stocks eu_stocks crypto bvc; do
nomad job run \
-var="allocation=${alloc}" \
-var="tap_image=${IMAGE}" \
jobs/marketdata-ingest.nomad
echo "✓ ${alloc} deployed"
done
# Monitor a running job
nomad alloc logs -f "$(nomad job allocs -json marketdata-ingest-us_stocks | jq -r '.[0].ID')"
A full historical backfill (30,000 instruments × 25 years) is a one-time operation taking several hours. After that, nightly incremental runs must complete in under 10 minutes. This requires proper bookmark management plus a deliberate lookback window.
If today is March 7 and the bookmark says March 6, we don't fetch only March 7. We fetch from March 4 onwards. This 3-day window covers three classes of late corrections:
adjclose
for recent bars. The 3-day window catches these without waiting for the full corporate actions pipeline
(Part 4) to run.Re-ingestion is safe because the PRIMARY KEY (provider_name, symbol, trade_date) makes every
COPY idempotent: rows already in the database conflict and are skipped. Corrected rows require an explicit
upsert—handled by dbt's unique_key incremental model in Part 3.
// validateBar blocks catastrophically invalid data before it enters the database.
// Comprehensive validation (range checks, cross-column assertions) belongs in dbt.
func validateBar(symbol, date string, open, high, low, close float64) error {
if close <= 0 || open <= 0 {
return fmt.Errorf("non-positive price: %s %s close=%.4f", symbol, date, close)
}
if high < low {
return fmt.Errorf("high < low: %s %s high=%.4f low=%.4f", symbol, date, high, low)
}
if close > high || close < low {
return fmt.Errorf("close outside high-low band: %s %s", symbol, date)
}
// Flag but don't reject extreme moves — may be a split (Part 4 handles these)
if open > 0 {
ret := (close - open) / open
if ret > 0.80 || ret < -0.80 {
slog.Warn("extreme daily return — possible split/error",
"symbol", symbol, "date", date, "return_pct", ret*100)
}
}
return nil
}
Rejecting data you can't fully evaluate in the tap means permanently losing it. An 80% daily move might be a split, a trading halt resumption, or a penny stock. The raw zone should capture everything; dbt tests (Part 3) will flag anomalies; the corporate actions pipeline (Part 4) will adjust price continuity. The Go tap only blocks values that would cause a PostgreSQL NUMERIC overflow or NaN insertion—both of which would crash the bulk COPY entirely.
Before moving to dbt transforms in Part 3, verify these concepts are solid.
asyncio fully solve the concurrency problem for financial data ingestion?asyncio releases the GIL during IO, allowing concurrent coroutines. But it requires every library
in the call chain to be async-native. Most financial libraries (requests, yfinance, TA-Lib, psycopg2) use
blocking C extensions that hold the GIL and cannot yield to the event loop. The workaround — running sync code
in a ThreadPoolExecutor or spawning full Python processes — reintroduces the overhead async was
supposed to eliminate. Go goroutines have no such constraint: the Go runtime scheduler handles context-switching
transparently at the language level, regardless of what the goroutine calls.
--state, allowing the tap to fetch only data since the
last successful run. Crash-safe: if the process dies before emitting STATE, the next run starts from the
previous bookmark, preventing data gaps.
DATE instead of TIMESTAMPTZ for trade_date?TIMESTAMPTZ requires a consistent timezone
assumption at ingestion time—but there is none. A Unix timestamp of 00:00 UTC converts to the
previous calendar day in US/Eastern (due to UTC-5 offset), creating phantom duplicates around midnight.
A bare DATE forces normalization in the Go tap: convert all timestamps to UTC date, strip the time
component. The semantic is then unambiguous: one row per exchange trading day per symbol per provider.
adjclose for recent bars. The 3-day window captures these adjustments without waiting for the
corporate actions pipeline to run. (3) API caching artifacts: Some Yahoo endpoints serve
stale cached data immediately after market close; fetching again the next day returns corrected values.
The raw zone's PRIMARY KEY ensures re-ingestion is idempotent (ON CONFLICT DO NOTHING); dbt handles
genuine corrections via unique_key upsert in the silver incremental model.
prohibit_overlap = true prevent in the Nomad periodic job?prohibit_overlap = true causes the scheduler
to simply skip the cron tick if the previous instance is still active.
transform: skip and run dbt as a separate Nomad job?transform: run is set, Meltano executes dbt immediately after extraction within the same
process. If a dbt model fails, Meltano marks the entire schedule as failed—which prevents the Singer
STATE bookmark from being saved. The next scheduled run would then re-fetch all data since the last saved
bookmark, including data that was already successfully loaded into raw_zone. With transform: skip:
the EL job saves its STATE bookmark regardless of what happens in dbt. The dbt job is a separate Nomad periodic
job with its own retry policy, its own logs, and its own failure domain. A SQL model bug never blocks ingestion.
raw_payload JSONB on every row alongside parsed columns?raw_payload still contains the data. Fixing the dbt bronze model with a corrected JSON path
expression is all that's needed; no re-extraction from the API is required. Additionally, the raw payload
enables forensic debugging: if a parsed price looks wrong, you can inspect exactly what the API returned
for that specific row without re-fetching. Never discard data you've already paid the network cost to retrieve.
tap-marketdata-go) with a semaphore-bounded goroutine pool (50 workers) ingesting 10,000 tickers in ~7 minutes vs 5.5 hours in Python.meltano.yml treating the Go binary as a first-class custom plugin, with transform: skip for independent EL and T retry semantics.prohibit_overlap = true and Vault-injected secrets.