Building a Securities Master — Part 2 of 6

Pipelines — Go Taps, Meltano & Nomad Orchestration

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.

Go Concurrency Singer Protocol Meltano EL Nomad Batch Jobs Incremental Extraction Raw Zone PostgreSQL
Securities Master 2/6
The Python Bottleneck

5.5 Hours vs. 7 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:

Python — Sequential Ingestion (naive)
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.

Daily Throughput at 50 Workers
Python asyncio: ~400 symbols/min   —   Go goroutines: ~3,000 symbols/min   —   Speedup: 7.5×

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.

Python GIL

Only one thread runs Python bytecode at a time. True IO parallelism requires multiprocessing (8 MB/process).

Go Goroutines

Start at 2 KB. Runtime schedules 50,000 of them. No GIL. Channels for safe cross-goroutine communication.

Singer Protocol

JSON to stdout. Language-agnostic contract. Meltano pipes it to PostgreSQL. Go writes, Python loads.

Nomad Batch

One Docker container per allocation. Isolated retries. No Airflow overhead. Native periodic cron semantics.

Why not Python asyncio + aiohttp?

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.

Raw Zone Schema

Dump First, Transform Later

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.

SQL — raw_zone schema creation
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)
);

Why 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.

Why store 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.

The Singer Protocol

A Language-Agnostic ELT Contract

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.

Message Type 1: SCHEMA — Stream Definition

// Emitted once per stream, before any RECORD messages.
// target-postgres uses this to CREATE/ALTER the destination table.
{
  "type": "SCHEMA",
  "stream": "raw_market_data",
  "schema": {
    "type": "object",
    "properties": {
      "symbol": { "type": "string" },
      "trade_date": { "type": "string", "format": "date" },
      "close_price": { "type": ["number", "null"] },
      "volume": { "type": ["number", "null"] }
    }
  },
  "key_properties": ["symbol", "trade_date"]
}

Message Type 2: RECORD — One Data Row

// One JSON line per row. The target buffers and batch-COPYs these.
{
  "type": "RECORD",
  "stream": "raw_market_data",
  "record": {
    "provider_name": "YAHOO",
    "symbol": "AAPL",
    "trade_date": "2026-03-07",
    "open_price": 214.32,
    "high_price": 219.80,
    "close_price": 218.50,
    "volume": 49823411
  }
}

Message Type 3: STATE — Incremental Bookmark

// Emitted at end of a successful run. Meltano persists this value atomically.
// Next run: Meltano passes it back via --state flag.
{
  "type": "STATE",
  "value": {
    "bookmarks": {
      "raw_market_data": {
        "last_fetched_date": "2026-03-07"
      }
    }
  }
}

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 vs. a custom internal HTTP API

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.

Building the Go Singer Tap

tap-marketdata — Full Implementation

The 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.

Thread-Safe Singer Emission

Go — internal/singer/emit.go
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
}

Yahoo Finance Extractor — Semaphore-Bounded Goroutines

Go — internal/yahoo/extractor.go
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
}

Casablanca Bourse (BVC) — Paginated JSON:API

Go — internal/bvc/extractor.go
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
}

Main Entry Point — State & Allocation Routing

Go — cmd/tap/main.go
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, Docker & Nomad

meltano.yml — The ELT Manifest

Meltano 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.

YAML — meltano.yml (key sections)
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.

Multi-Stage Docker Build

Dockerfile — Go binary inside Meltano container
# ─── 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 Periodic Jobs

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.

HCL — jobs/marketdata-ingest.nomad
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 critical

Without 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.

Shell — build and deploy all four allocations
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')"

Data Source Universe by Allocation

Yahoo Finance

EQUITY ETF CRYPTO
~50,000 global symbols • ~2,000 req/hour per IP
Format: JSON v8 chart API • Auth: User-Agent header

Casablanca Bourse

EQUITY BOND
~75 listed companies • Strict: 240 req/hour
Format: JSON:API paginated • Auth: None (public)

Binance

CRYPTO FUTURES
~350 spot, ~250 perp futures • 1,200 req/min
Format: REST JSON • Auth: API key (public endpoints free)

StockAnalysis.com

UNIVERSE LISTS
Full NASDAQ/NYSE/AMEX ticker lists
Use: populate symbol files for the Yahoo tap
Incremental Extraction & Quality Gates

The 3-Day Lookback Window

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:

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.

Incremental Fetch Window
fetch_from = bookmark_date − 3 days     raw_zone insert: ON CONFLICT DO NOTHING     dbt silver upsert: unique_key MERGE

Ingestion-Time Quality Gate

Go — lightweight validation before EmitRecord
// 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
}

Don't over-validate at ingestion time

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.

Quiz

Comprehension Check

Before moving to dbt transforms in Part 3, verify these concepts are solid.

Q1 — Why doesn't Python's 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.
Q2 — What are the three Singer message types? What does each accomplish?
SCHEMA: Defines the JSON schema and primary key(s) for a named stream. Emitted once per stream before any RECORD. The target uses this to CREATE/ALTER the destination table.

RECORD: One row of data. A JSON object matching the SCHEMA declaration. The target buffers these and bulk-COPYs them into PostgreSQL in batches (default: 10,000 rows).

STATE: A bookmark that the target persists atomically after a successful flush. On the next run, Meltano passes this value back via --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.
Q3 — Why use DATE instead of TIMESTAMPTZ for trade_date?
Providers represent trading dates differently. Yahoo Finance returns Unix timestamps in UTC; Casablanca Bourse returns bare date strings 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 (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.
Q4 — Explain the 3-day lookback window. What three problems does it solve?
(1) Volume revisions: Exchanges often correct prior-day volume the following business day, sometimes T+2. Without the lookback, these corrections would be missed until the next full backfill. (2) Adjusted close updates: When a dividend is declared, Yahoo retroactively updates 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.
Q5 — What does prohibit_overlap = true prevent in the Nomad periodic job?
Without it, if the nightly job runs for more than 24 hours (possible during a multi-week backfill after an outage), Nomad would launch a second instance while the first is still running. Two concurrent instances would: (1) compete for Yahoo Finance rate limit budget, increasing 429 errors across the board; (2) attempt simultaneous COPY operations into the same year partitions, causing PRIMARY KEY conflicts and PostgreSQL table-level lock contention; (3) race on the Singer STATE bookmark, where both instances write their own STATE, potentially causing the bookmark to regress. prohibit_overlap = true causes the scheduler to simply skip the cron tick if the previous instance is still active.
Q6 — Why set transform: skip and run dbt as a separate Nomad job?
When 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.
Q7 — Why store raw_payload JSONB on every row alongside parsed columns?
The raw_payload is an operational insurance policy against API schema changes. When a provider renames a field—Yahoo Finance did this multiple times in 2022–2023—parsed columns become NULL but 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.
Up Next — Part 3/6
Transforms — The dbt Medallion Architecture

What We Built in Part 2

Securities Master 2/6
The Python Bottleneck Raw Zone Schema Singer Protocol Go Tap Implementation Meltano, Docker & Nomad Incremental Extraction Quiz