Building a Securities Master — Part 3 de 6

Transforms — Architecture Médaillon avec dbt

Le JSON brut est toxique pour les modèles quantitatifs. Construisez un pipeline rigoureux avec dbt : vues Bronze, modèles incrémentiels Silver, snapshots SCD Type 2 automatisés, et marts Gold prêts pour l'IA.

Bronze / Silver / Gold dbt Core Snapshots SCD2 Modèles Incrémentiels
Securities Master 3/6
Introduction Structure dbt Couche Bronze Silver — Incrémental Silver — SCD2 Qualité des Données Couche Gold Orchestration Nomad Quiz

Dans la Partie 1, nous avons conçu le schéma PostgreSQL institutionnel avec SCD Type 2, tables de référence et partitionnement. Dans la Partie 2, des extracteurs Go concurrents orchestrés par Nomad et Meltano pompent les APIs vers notre raw_zone. Maintenant, nous faisons face au chaos.

La raw_zone déborde de JSON brut. L'API Casablanca Bourse produit des propriétés imbriquées non documentées. Yahoo Finance renvoie des CSV plats avec des conventions de colonnes changeantes. Binance utilise BTCUSDT là où d'autres attendent BTC-USD. Si un chercheur quant interroge ces données directement, votre fonds perd de l'argent à cause de mauvaises données.

La solution : l'Architecture Médaillon implémentée avec dbt Core.

Vue d'ensemble

La Logique Médaillon en Finance Quantitative

Le pattern Médaillon (Bronze, Silver, Gold) n'est pas un buzzword — c'est un mécanisme de défense ingénierie. Il isole le chaos des fournisseurs externes de la logique fragile de vos algorithmes de trading. Chaque couche a un contrat strict.

Bronze : Brut & Casté

Matérialisation : view

Schema-on-Read. Extrait les clés JSON, caste en types PostgreSQL stricts (NUMERIC(14,4)), normalise les noms de colonnes entre providers. Zéro coût de stockage.

Couche de déduplication

Silver : Nettoyé & Historisé

Matérialisation : incremental + snapshot

Source de vérité. Déduplication, résolution des conflits entre providers, et tracking temporel SCD Type 2. Milliards de barres OHLCV fusionnées de façon incrémentielle.

Point-in-time correct

Gold : Marts IA & Stratégie

Matérialisation : table

Schémas en étoile dé-normalisés. Indicateurs techniques pré-calculés (ATR, Volatilité Rolling, Volume Dollar). Un agent LLM ou un outil BI interroge le Gold layer sans écrire de fenêtres complexes.

Interface IA prête

Flux de données Médaillon — du JSON brut au mart IA
Structure du Projet dbt

Organisation du Projet dbt

Avant d'écrire le moindre modèle SQL, la structure de répertoires est critique. dbt utilise une convention de nommage pour déduire la couche Médaillon et appliquer les configurations par défaut définies dans dbt_project.yml.

Arborescence du projet dbt
securities_dbt/ ├── models/ │ ├── bronze/ # Views — zéro coût stockage │ │ ├── stg_yahoo_bars.sql │ │ ├── stg_bvc_bars.sql │ │ ├── stg_bvc_instruments.sql │ │ ├── stg_binance_bars.sql │ │ └── schema.yml │ ├── silver/ # Incrémental + Snapshots │ │ ├── fct_market_data_daily.sql │ │ ├── fct_market_data_intraday.sql │ │ └── schema.yml │ └── gold/ # Tables matérialisées pour BI/IA │ ├── mart_strategy_universe.sql │ ├── mart_sector_momentum.sql │ └── schema.yml ├── snapshots/ # SCD Type 2 automatisé │ └── dim_instruments_snapshot.sql ├── tests/ # Tests personnalisés │ ├── assert_no_future_dates.sql │ └── assert_positive_prices.sql ├── macros/ # Jinja2 réutilisable │ └── dedupe_cte.sql ├── dbt_project.yml ├── profiles.yml # Connexion PostgreSQL └── packages.yml # dbt-expectations, etc.
yaml — dbt_project.yml (configurations par couche)
name: 'securities_dbt'
version: '1.7.0'
config-version: 2

profile: 'securities_master'

model-paths: ["models"]
snapshot-paths: ["snapshots"]
test-paths: ["tests"]
macro-paths: ["macros"]

models:
  securities_dbt:
    # Couche Bronze : vue légère, pas de stockage
    bronze:
      +materialized: view
      +schema: bronze_zone
      +tags: ['bronze']

    # Couche Silver : tables incrémentielles persistantes
    silver:
      +materialized: incremental
      +schema: silver_zone
      +tags: ['silver']

    # Couche Gold : tables rebuild complètes chaque nuit
    gold:
      +materialized: table
      +schema: gold_zone
      +tags: ['gold']

snapshots:
  securities_dbt:
    +target_schema: silver_zone
    +tags: ['scd2', 'silver']

La configuration centralisée dans dbt_project.yml évite de répéter materialized dans chaque fichier SQL. Chaque modèle dans models/bronze/ hérite automatiquement de view. Vous pouvez toujours écraser localement avec {{ config(materialized='table') }}.

yaml — models/bronze/schema.yml (sources + documentation)
version: 2

sources:
  - name: raw_zone
    description: "Données brutes ingérées par les taps Meltano/Go (Part 2)"
    database: securities_master
    schema: raw_zone
    tables:
      - name: raw_instruments
        description: "Payloads JSON des instruments financiers par provider"
        columns:
          - name: external_id
            description: "Identifiant du provider (ticker, ISIN, etc.)"
            tests:
              - not_null
          - name: raw_payload
            description: "JSONB complet du provider"
            tests:
              - not_null
          - name: provider_name
            description: "Nom du provider (BVC, YAHOO, BINANCE)"
            tests:
              - not_null
              - accepted_values:
                  values: ['BVC', 'YAHOO', 'BINANCE', 'EURONEXT', 'CBOE']
          - name: _sdc_extracted_at
            description: "Timestamp d'ingestion Meltano (Singer _sdc_extracted_at)"
            tests:
              - not_null

      - name: raw_market_data
        description: "Données OHLCV brutes par provider"
        columns:
          - name: symbol
            tests: [not_null]
          - name: trade_date
            tests: [not_null]
          - name: raw_payload
            tests: [not_null]

Pourquoi source() plutôt que référence directe aux tables ?

{{ source('raw_zone', 'raw_instruments') }} permet à dbt de tracer le lineage complet de la donnée : de la table raw ingérée par le tap Go jusqu'au mart Gold final. Le dbt docs generate produit un graphe de dépendances interactif. Les tests sur les sources (not_null, accepted_values) créent un firewall de qualité à l'entrée du pipeline.

Couche Bronze

Bronze : Dompter le JSON avec PostgreSQL

On n'écrit jamais d'instructions INSERT avec du parsing JSON manuel. On définit des vues dbt sur les tables raw. Si un provider change Adj Close en adjusted_close demain, on met à jour uniquement la vue Bronze. Tout le pipeline aval reste protégé.

1
Extraire
Opérateurs JSONB PostgreSQL (->>, ->) pour extraire les valeurs.
2
Caster
Conversion explicite : ::NUMERIC(14,4), ::DATE, ::CHAR(12).
3
Normaliser
Standardiser les noms de colonnes entre providers (snake_case, même sémantique).
4
Dédupliquer
CTE avec ROW_NUMBER() + WHERE rn = 1 — compatible PostgreSQL.

Exemple : Instruments BVC (Bourse de Casablanca)

dbt SQL — models/bronze/stg_bvc_instruments.sql
-- Héritage de dbt_project.yml : materialized='view', schema='bronze_zone'
{{ config(tags=['bronze', 'reference_data']) }}

WITH raw_bvc AS (
    -- {{ source() }} documente le lineage dans dbt docs
    SELECT *
    FROM {{ source('raw_zone', 'raw_instruments') }}
    WHERE provider_name = 'BVC'
),

extracted AS (
    SELECT
        -- Métadonnées Singer/Meltano (injectées par le tap Go en Part 2)
        _sdc_extracted_at                                 AS ingested_at,

        -- Extraction JSONB PostgreSQL + casting strict
        external_id::VARCHAR(50)                          AS provider_tick,
        (raw_payload->>'codeISIN')::CHAR(12)              AS isin,
        (raw_payload->>'instrumentName')::TEXT            AS name,
        (raw_payload->>'currency')::CHAR(3)               AS currency,

        -- JSONB imbriqué : tableaux de secteurs
        -- jsonb_array_element_text() extrait l'index 0 d'un tableau JSON
        jsonb_array_element_text(
            raw_payload->'sectors', 0
        )::TEXT                                           AS sector_primary,

        -- Standardisation vers notre modèle de données (Part 1)
        UPPER(raw_payload->>'instrumentType')::VARCHAR(50) AS instrument_type,
        (raw_payload->>'isTradable')::BOOLEAN              AS is_tradable,

        -- Coordonnées boursières
        (raw_payload->>'marketCap')::NUMERIC(20,2)         AS market_cap_mad,
        (raw_payload->>'floatRatio')::NUMERIC(6,4)         AS float_ratio

    FROM raw_bvc
),

-- ⚠ Déduplication PostgreSQL correcte : CTE + ROW_NUMBER
-- NOTE: QUALIFY n'existe PAS en PostgreSQL (BigQuery/Snowflake uniquement).
-- Pattern correct : CTE intermédiaire + WHERE rn = 1
deduped AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY isin
            ORDER BY ingested_at DESC   -- garder l'ingestion la plus récente
        ) AS rn
    FROM extracted
    WHERE isin IS NOT NULL              -- filtrer les instruments sans ISIN
)

SELECT
    ingested_at,
    provider_tick,
    isin,
    name,
    currency,
    sector_primary,
    instrument_type,
    is_tradable,
    market_cap_mad,
    float_ratio
FROM deduped
WHERE rn = 1    -- strictement le payload le plus récent par ISIN

Bug critique à éviter : QUALIFY n'existe pas en PostgreSQL

La syntaxe QUALIFY ROW_NUMBER() OVER (...) = 1 est exclusive à BigQuery, Snowflake, et DuckDB. Elle lève une erreur de syntaxe sur PostgreSQL.

Le pattern correct PostgreSQL est un CTE avec ROW_NUMBER() calculé dans une sous-requête, puis filtré avec WHERE rn = 1 dans la requête externe. dbt rend ce pattern naturel grâce aux CTEs enchaînées.

Pourquoi JSONB opérateur ->> et non -> ?

-> retourne un objet JSONB (avec les guillemets JSON inclus). ->> retourne une chaîne de texte, ce qui permet le cast direct : (raw_payload->>'marketCap')::NUMERIC. Utiliser -> avec un cast numérique provoque une erreur de type. Pour les tableaux JSON imbriqués, utiliser jsonb_array_element_text(payload->'arrayKey', index).

Exemple : Barres OHLCV Yahoo Finance

dbt SQL — models/bronze/stg_yahoo_bars.sql
{{ config(tags=['bronze', 'market_data']) }}

WITH raw_yahoo AS (
    SELECT *
    FROM {{ source('raw_zone', 'raw_market_data') }}
    WHERE provider_name = 'YAHOO'
),

extracted AS (
    SELECT
        _sdc_extracted_at                               AS ingested_at,
        (raw_payload->>'symbol')::VARCHAR(20)           AS symbol,

        -- Yahoo renvoie trade_date comme string ISO 8601 'YYYY-MM-DD'
        (raw_payload->>'date')::DATE                    AS trade_date,

        -- OHLCV : Yahoo utilise 'Open', 'High', 'Low', 'Close' (capitalised)
        (raw_payload->>'Open')::NUMERIC(14,4)           AS open_price,
        (raw_payload->>'High')::NUMERIC(14,4)           AS high_price,
        (raw_payload->>'Low')::NUMERIC(14,4)            AS low_price,
        (raw_payload->>'Close')::NUMERIC(14,4)          AS close_price,
        (raw_payload->>'Adj Close')::NUMERIC(14,4)      AS adj_close_price,
        (raw_payload->>'Volume')::BIGINT                AS volume,

        -- VWAP n'est pas fourni par Yahoo, on l'estime (HLC/3)
        NULL::NUMERIC(14,4)                             AS vwap,

        -- Validation de base : filtrer les barres sans prix valide
        CASE
            WHEN (raw_payload->>'Close') IS NULL  THEN FALSE
            WHEN (raw_payload->>'Volume') = '0'   THEN FALSE
            ELSE TRUE
        END AS is_valid

    FROM raw_yahoo
),

deduped AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY symbol, trade_date
            ORDER BY ingested_at DESC
        ) AS rn
    FROM extracted
    WHERE is_valid = TRUE
      AND trade_date IS NOT NULL
      AND symbol IS NOT NULL
)

SELECT
    ingested_at, symbol, trade_date,
    open_price, high_price, low_price, close_price, adj_close_price,
    volume, vwap
FROM deduped
WHERE rn = 1
Silver — Modèles Incrémentiels

Traiter des Milliards de Barres avec les Modèles Incrémentiels

Avec 10 000 actions mondiales et 25 ans d'historique journalier, nous traitons 60+ millions de lignes. Les données intraday (barres 1 minute) atteignent plusieurs dizaines de milliards de lignes. Matérialiser cela en table dbt — qui exécute un DROP TABLE puis CREATE TABLE AS SELECT à chaque run — serait catastrophique.

La matérialisation incremental de dbt résout ce problème. Au premier run, elle construit la table complète. À chaque run suivant, dbt génère un INSERT ... ON CONFLICT DO UPDATE, ne traitant que les nouvelles données.

Économie de traitement incrémentiel
Données traitées = WHERE trade_date ≥ MAX(trade_date) - INTERVAL '3 days'
Sur 60M lignes : 60M → ~150K lignes traitées par run = 400× plus rapide
dbt SQL — models/silver/fct_market_data_daily.sql
-- Configuration du modèle incrémentiel
-- ⚠ PostgreSQL : PAS de partition_by dans config dbt
--   (c'est une option BigQuery-specific)
--   Le partitionnement PostgreSQL est défini au niveau DDL (fait en Part 1)
{{ config(
    materialized='incremental',
    unique_key=['symbol', 'trade_date'],
    on_schema_change='fail',
    tags=['silver', 'market_data']
) }}

WITH combined_bronze AS (
    -- Union de toutes les sources Bronze normalisées
    SELECT * FROM {{ ref('stg_yahoo_bars') }}
    UNION ALL
    SELECT * FROM {{ ref('stg_bvc_bars') }}
    UNION ALL
    SELECT * FROM {{ ref('stg_binance_bars') }}
),

enriched AS (
    SELECT
        symbol,
        trade_date,
        open_price,
        high_price,
        low_price,
        close_price,
        adj_close_price,
        volume,

        -- VWAP proxy si le provider ne le fournit pas (HLC/3)
        COALESCE(
            vwap,
            (high_price + low_price + close_price) / 3.0
        ) AS vwap_proxy,

        -- True Range pour calcul ATR ultérieur en Gold
        high_price - low_price AS true_range_hl,

        ingested_at,
        CURRENT_TIMESTAMP AS dbt_updated_at

    FROM combined_bronze
    WHERE close_price > 0       -- sanity check
      AND volume > 0
      AND trade_date <= CURRENT_DATE  -- pas de barres futures
)

SELECT * FROM enriched

{% if is_incremental() %}
    -- ⚡ Block INCRÉMENTIEL : exécuté uniquement sur les runs de mise à jour
    -- Lookback de 3 jours pour capturer :
    --   - Les corrections tardives de volumes envoyées par les exchanges
    --   - Les dividendes ajustés rétroactivement par Yahoo Finance
    --   - Les splits qui recalculent l'historique adj_close
    WHERE trade_date >= (
        SELECT MAX(trade_date) - INTERVAL '3 days'
        FROM {{ this }}
    )
{% endif %}

Pourquoi la fenêtre de 3 jours, pas > MAX(trade_date) ?

Les données financières arrivent avec un délai. Yahoo Finance corrige silencieusement ses prix ajustés des dividendes plusieurs jours après le paiement. Les exchanges envoient parfois un volume révisé le lendemain. Avec > MAX(trade_date), ces corrections ne seraient jamais ingérées.

La fenêtre de 3 jours garantit qu'on retraite les 3 derniers jours. Avec unique_key=['symbol', 'trade_date'], dbt génère un vrai upsert : si la ligne existe, elle est mise à jour ; sinon elle est insérée. On capture les corrections tout en traitant 400× moins de données qu'un rebuild complet.

partition_by dans la config dbt : erreur BigQuery

La clé partition_by={'field': 'trade_date', 'data_type': 'date', 'granularity': 'month'} dans le bloc config() dbt est exclusive à BigQuery. Sur PostgreSQL, elle est silencieusement ignorée ou provoque des erreurs selon la version de l'adaptateur.

Le partitionnement PostgreSQL est défini au niveau DDL PARTITION BY RANGE (trade_date) lors de la création initiale de la table (fait dans la Partie 1). dbt insère ensuite dans la bonne partition automatiquement — sans configuration supplémentaire dans le modèle.

Barres Intraday : Gestion des Volumes Extrêmes

dbt SQL — models/silver/fct_market_data_intraday.sql
{{ config(
    materialized='incremental',
    unique_key=['symbol', 'bar_timestamp'],
    -- Sur 50+ milliards de lignes, on vérifie d'abord si le schema a changé
    on_schema_change='fail',
    tags=['silver', 'intraday']
) }}

WITH raw_intraday AS (
    SELECT *
    FROM {{ source('raw_zone', 'raw_market_data') }}
    WHERE provider_name IN ('BINANCE', 'BVC_INTRADAY')
      AND (raw_payload->>'interval') = '1m'
),

extracted AS (
    SELECT
        (raw_payload->>'symbol')::VARCHAR(20)           AS symbol,

        -- Timestamp complet avec timezone
        TO_TIMESTAMP(
            (raw_payload->>'open_time')::BIGINT / 1000.0
        ) AT TIME ZONE 'UTC'                            AS bar_timestamp,

        (raw_payload->>'open')::NUMERIC(14,8)           AS open_price,
        (raw_payload->>'high')::NUMERIC(14,8)           AS high_price,
        (raw_payload->>'low')::NUMERIC(14,8)            AS low_price,
        (raw_payload->>'close')::NUMERIC(14,8)          AS close_price,
        (raw_payload->>'volume')::NUMERIC(24,8)         AS volume,
        (raw_payload->>'quote_asset_volume')::NUMERIC(24,8) AS quote_volume,
        (raw_payload->>'number_of_trades')::INTEGER     AS num_trades,
        _sdc_extracted_at                               AS ingested_at

    FROM raw_intraday
),

deduped AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY symbol, bar_timestamp
            ORDER BY ingested_at DESC
        ) AS rn
    FROM extracted
    WHERE close_price > 0
)

SELECT
    symbol, bar_timestamp, open_price, high_price, low_price,
    close_price, volume, quote_volume, num_trades, ingested_at
FROM deduped
WHERE rn = 1

{% if is_incremental() %}
    -- Pour les barres intraday, lookback de 2 heures suffit
    -- (les exchanges corrigent rarement au-delà d'une heure)
    AND bar_timestamp >= (
        SELECT MAX(bar_timestamp) - INTERVAL '2 hours'
        FROM {{ this }}
    )
{% endif %}
Volume de données par couche Médaillon — Impact du traitement incrémentiel
Silver — SCD Type 2 avec dbt Snapshots

Automatiser SCD Type 2 avec dbt Snapshots

Dans la Partie 1, nous avons conçu le framework théorique : quand les métadonnées fondamentales changent (Apple change de secteur GICS, une action est renommée suite à une fusion), on ne doit pas écraser la ligne. Il faut fermer le valid_to de l'ancienne version et insérer une nouvelle version avec valid_from = NOW(). Coder ces merges SQL manuellement est notoire pour produire des bugs subtils.

dbt Snapshots automatise entièrement cette logique. Vous déclarez quelles colonnes surveiller ; dbt calcule les hashes, détecte les mutations, et émet les UPDATE/INSERT appropriés.

dbt snapshot — snapshots/dim_instruments_snapshot.sql
{% snapshot dim_instruments_snapshot %}

{{
    config(
        target_schema='silver_zone',
        unique_key='isin',

        -- Stratégie 'check' : dbt compare un hash des colonnes listées
        -- Si le hash change → nouvelle version SCD2
        strategy='check',
        check_cols=[
            'name',
            'sector_primary',
            'instrument_type',
            'is_tradable',
            'currency',
            'market_cap_mad',      -- Un changement de taille matérielle est documenté
        ],

        -- Colonnes dbt_scd_id, dbt_updated_at, dbt_valid_from, dbt_valid_to
        -- sont automatiquement ajoutées par dbt
        invalidate_hard_deletes=true,   -- Marquer dbt_valid_to si l'ISIN disparaît
    )
}}

-- Source : la vue Bronze qui agrège tous les providers d'instruments
SELECT
    isin,
    provider_tick,
    name,
    instrument_type,
    sector_primary,
    currency,
    is_tradable,
    market_cap_mad,
    float_ratio,
    ingested_at
FROM {{ ref('stg_all_instruments') }}

-- On n'inclut que les instruments avec ISIN valide
WHERE isin IS NOT NULL
  AND LENGTH(TRIM(isin)) = 12

{% endsnapshot %}

Comment dbt Snapshot génère-t-il le SCD Type 2 ?

À chaque run de dbt snapshot, dbt exécute un MERGE en deux étapes : d'abord, il compare le hash des check_cols de chaque ligne entrante avec la version active (où dbt_valid_to IS NULL). Si le hash change, dbt émet un UPDATE pour fermer l'ancienne ligne (dbt_valid_to = NOW()) puis un INSERT pour la nouvelle version (dbt_valid_from = NOW(), dbt_valid_to = NULL). Si le hash est identique, rien ne se passe. C'est exactement l'architecture point-in-time que nous avons codée manuellement en Partie 1, ici générée automatiquement.

La table générée silver_zone.dim_instruments_snapshot produit automatiquement les colonnes suivantes :

Colonne Type Description Valeur si actif
dbt_scd_id TEXT Hash unique de la version UUID généré
dbt_updated_at TIMESTAMP Dernière mise à jour de la ligne Timestamp du run
dbt_valid_from TIMESTAMP Début de validité de cette version Date de création
dbt_valid_to TIMESTAMP Fin de validité (NULL = version actuelle) NULL
isin + tous vos champs Vos types Données d'instrument à ce moment Valeurs actuelles
SQL — Interroger les snapshots SCD2 (version point-in-time)
-- Exemple : Quel secteur avait Apple (ISIN US0378331005) au 15 janvier 2023 ?
-- Requête point-in-time correcte grâce aux snapshots dbt
SELECT
    isin,
    name,
    sector_primary,
    instrument_type,
    dbt_valid_from,
    dbt_valid_to
FROM silver_zone.dim_instruments_snapshot
WHERE isin = 'US0378331005'
  -- Point-in-time : la version valide à cette date
  AND dbt_valid_from <= '2023-01-15'::TIMESTAMP
  AND (dbt_valid_to > '2023-01-15'::TIMESTAMP OR dbt_valid_to IS NULL)
ORDER BY dbt_valid_from;

-- Pour obtenir uniquement la version actuelle (toutes les actions)
SELECT *
FROM silver_zone.dim_instruments_snapshot
WHERE dbt_valid_to IS NULL
  AND is_tradable = TRUE
ORDER BY market_cap_mad DESC NULLS LAST;

Vue helper : masquer la complexité SCD2

dbt SQL — models/silver/dim_instruments_current.sql
-- Vue de commodité : expose uniquement la version actuelle de chaque instrument
-- Les utilisateurs en aval n'ont pas à gérer dbt_valid_to IS NULL
{{ config(materialized='view', tags=['silver', 'reference_data']) }}

SELECT
    isin,
    provider_tick,
    name,
    instrument_type,
    sector_primary,
    currency,
    is_tradable,
    market_cap_mad,
    float_ratio,
    dbt_valid_from AS last_updated
FROM {{ ref('dim_instruments_snapshot') }}
WHERE dbt_valid_to IS NULL
Qualité des Données

Faire Confiance aux Données : Tests Automatisés dbt

Si vous passez des NaN à un backtester Python, il plante. Si vous passez des données erronées à un algorithme de trading live, il perd de l'argent. Nous utilisons les assertions de test dbt comme une porte CI/CD stricte. Les données n'atteignent pas la couche Gold si des tests échouent.

yaml — models/silver/schema.yml (tests automatisés)
version: 2

models:
  - name: fct_market_data_daily
    description: "Barres OHLCV journalières — source de vérité du Securities Master"

    columns:
      - name: symbol
        description: "Symbole boursier normalisé"
        tests:
          - not_null
          - relationships:
              to: ref('dim_instruments_current')
              field: provider_tick

      - name: trade_date
        description: "Date de la séance boursière"
        tests:
          - not_null
          - dbt_expectations.expect_column_values_to_be_between:
              min_value: "'1990-01-01'::DATE"
              max_value: "CURRENT_DATE"

      - name: close_price
        description: "Prix de clôture en devise du marché"
        tests:
          - not_null
          # Prix ne peut être négatif ou zéro
          - dbt_expectations.expect_column_values_to_be_between:
              min_value: 0.0001
              strictly: false

      - name: volume
        tests:
          - not_null
          - dbt_expectations.expect_column_values_to_be_between:
              min_value: 0

      - name: high_price
        tests:
          # High doit toujours être >= Close
          - dbt_utils.expression_is_true:
              expression: ">= close_price"

      - name: low_price
        tests:
          # Low doit toujours être <= Close
          - dbt_utils.expression_is_true:
              expression: "<= close_price"

    tests:
      # Unicité composite : une seule barre par symbole par jour
      - unique:
          column_name: "symbol || '-' || trade_date::TEXT"
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns:
            - symbol
            - trade_date

  - name: dim_instruments_current
    description: "Version courante de tous les instruments actifs"
    columns:
      - name: isin
        tests:
          - not_null
          - unique
          - dbt_expectations.expect_column_value_lengths_to_equal:
              value: 12

      - name: instrument_type
        tests:
          # Rejet si un provider invente une nouvelle classe d'actif non documentée
          - accepted_values:
              values: ['EQUITY', 'FUTURE', 'FXFORWARD', 'IRS', 'OPTIONVOL', 'CRYPTO', 'ETF']

      - name: currency
        tests:
          - not_null
          - dbt_expectations.expect_column_value_lengths_to_equal:
              value: 3

Pour les tests métier complexes, dbt supporte des tests SQL personnalisés dans tests/. Ces tests doivent retourner 0 ligne pour passer.

SQL — tests/assert_ohlcv_consistency.sql (test personnalisé)
-- Test personnalisé : vérifie la cohérence OHLCV interne
-- Un chandelier valide doit respecter : Low <= Open, Close <= High
-- Ce test retourne les lignes invalides — dbt échoue si résultat non vide

SELECT
    symbol,
    trade_date,
    open_price,
    high_price,
    low_price,
    close_price,
    'OHLCV_INCONSISTENCY' AS failure_reason
FROM {{ ref('fct_market_data_daily') }}
WHERE
    -- Open en dehors du range High/Low
    open_price > high_price
    OR open_price < low_price
    -- Close en dehors du range High/Low
    OR close_price > high_price
    OR close_price < low_price
    -- High inférieur à Low (impossible physiquement)
    OR high_price < low_price
SQL — tests/assert_no_weekend_bars.sql
-- Les marchés actions sont fermés le weekend
-- Ce test détecte les barres Saturday/Sunday pour les actions (pas crypto)
-- Retourne les violations → dbt échoue si non vide

SELECT
    f.symbol,
    f.trade_date,
    EXTRACT(DOW FROM f.trade_date) AS day_of_week,   -- 0=Sun, 6=Sat
    'WEEKEND_BAR' AS failure_reason
FROM {{ ref('fct_market_data_daily') }} f
JOIN {{ ref('dim_instruments_current') }} d
    ON f.symbol = d.provider_tick
WHERE d.instrument_type = 'EQUITY'       -- Uniquement les actions
  AND EXTRACT(DOW FROM f.trade_date) IN (0, 6)   -- Weekend
Test Type Modèle Sévérité Action si échec
not_null sur symbol/date Built-in Bronze + Silver error Pipeline bloqué
Prix positif (> 0.0001) dbt-expectations Silver error Pipeline bloqué
Cohérence OHLCV Custom SQL Silver warn Alerte, pipeline continue
Unicité symbol+date Built-in Silver error Pipeline bloqué
instrument_type accepté Built-in Silver error Pipeline bloqué
Barres weekend (actions) Custom SQL Silver warn Log + investigation
Longueur ISIN = 12 dbt-expectations Silver error Pipeline bloqué
Relation instruments actifs Built-in Silver warn Alerte, pipeline continue
Couche Gold

Gold : Le Mart de Découverte Stratégique pour l'IA

La couche Silver est parfaitement structurée mais normalisée. Demander à un Data Scientist ou un LLM d'écrire dynamiquement des fonctions de fenêtre pour calculer des ATR sur 20 jours en traversant des dimensions SCD2 est masochiste et source d'erreurs.

La couche Gold abandonne la normalisation. Nous utilisons dbt pour pré-calculer des tables larges en schéma étoile, optimisées pour des SELECT rapides. Quand votre scanner systématique cherche chaque matin des "Candidats Breakout Haute Volatilité Très Liquides", il frappe la couche Gold.

dbt SQL — models/gold/mart_strategy_universe.sql
{{ config(
    materialized='table',
    tags=['gold', 'ai_marts'],
    post_hook="ANALYZE {{ this }}"   -- Mettre à jour les stats PostgreSQL après rebuild
) }}

WITH active_instruments AS (
    -- Cacher la complexité SCD2 aux utilisateurs downstream
    -- On expose uniquement CE QUI est tradable AUJOURD'HUI
    SELECT *
    FROM {{ ref('dim_instruments_current') }}
    WHERE is_tradable = TRUE
),

-- Derniers 252 jours de données (1 an de trading)
recent_bars AS (
    SELECT *
    FROM {{ ref('fct_market_data_daily') }}
    WHERE trade_date >= CURRENT_DATE - INTERVAL '252 days'
),

technicals AS (
    SELECT
        symbol,
        trade_date,
        close_price,
        volume,
        adj_close_price,

        -- ══ Indicateurs de Liquidité ══
        -- Average Daily Volume en devise (20 jours)
        AVG(volume * close_price) OVER w_20d          AS adv_20d_usd,

        -- ══ Indicateurs de Volatilité ══
        -- Volatilité réalisée 20 jours (écart-type des rendements log)
        STDDEV(
            LN(close_price / LAG(close_price, 1) OVER w_all)
        ) OVER w_20d * SQRT(252)                      AS realized_vol_20d_annualized,

        -- True Range pour ATR
        GREATEST(
            high_price - low_price,
            ABS(high_price - LAG(close_price, 1) OVER w_all),
            ABS(low_price  - LAG(close_price, 1) OVER w_all)
        )                                             AS true_range,

        -- ══ Indicateurs de Tendance ══
        AVG(close_price) OVER w_20d                   AS sma_20d,
        AVG(close_price) OVER w_50d                   AS sma_50d,
        AVG(close_price) OVER w_200d                  AS sma_200d,

        -- Distance par rapport aux moyennes (momentum)
        (close_price / NULLIF(AVG(close_price) OVER w_50d, 0) - 1) * 100
                                                      AS pct_above_sma50,

        -- ══ Performance Periods ══
        -- Rendement 1 mois (21 jours de trading)
        (close_price / NULLIF(
            LAG(close_price, 21) OVER w_all, 0
        ) - 1) * 100                                  AS perf_1m_pct,

        -- Rendement 3 mois (63 jours de trading)
        (close_price / NULLIF(
            LAG(close_price, 63) OVER w_all, 0
        ) - 1) * 100                                  AS perf_3m_pct

    FROM {{ ref('fct_market_data_daily') }}
    JOIN active_instruments USING (symbol)

    WINDOW
        -- Fenêtres nommées réutilisées : lisibles et optimisées
        w_all  AS (PARTITION BY symbol ORDER BY trade_date
                   ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),
        w_20d  AS (PARTITION BY symbol ORDER BY trade_date
                   ROWS BETWEEN 20 PRECEDING AND CURRENT ROW),
        w_50d  AS (PARTITION BY symbol ORDER BY trade_date
                   ROWS BETWEEN 50 PRECEDING AND CURRENT ROW),
        w_200d AS (PARTITION BY symbol ORDER BY trade_date
                   ROWS BETWEEN 200 PRECEDING AND CURRENT ROW)
),

-- ATR 14 jours : moyenne des true ranges
atr_calc AS (
    SELECT
        symbol,
        trade_date,
        AVG(true_range) OVER (
            PARTITION BY symbol ORDER BY trade_date
            ROWS BETWEEN 14 PRECEDING AND CURRENT ROW
        ) AS atr_14d
    FROM technicals
),

-- Snapshot du dernier jour uniquement pour la table Gold
latest_day AS (
    SELECT DISTINCT ON (symbol)
        t.*,
        a.atr_14d
    FROM technicals t
    JOIN atr_calc a USING (symbol, trade_date)
    ORDER BY symbol, t.trade_date DESC
)

-- ══ Table Gold finale : large, dé-normalisée, IA-ready ══
SELECT
    -- Dimensions de référence (depuis Silver SCD2)
    ref.isin,
    ref.provider_tick                                  AS symbol,
    ref.name                                           AS company_name,
    ref.sector_primary                                 AS sector,
    ref.instrument_type,
    ref.currency,
    ref.market_cap_mad,

    -- Prix et date
    ld.trade_date                                      AS last_price_date,
    ld.close_price,
    ld.adj_close_price,

    -- Liquidité
    ROUND(ld.adv_20d_usd::NUMERIC, 0)                 AS adv_20d_usd,

    -- Volatilité et ATR
    ROUND(ld.realized_vol_20d_annualized::NUMERIC, 4)  AS realized_vol_20d,
    ROUND(ld.atr_14d::NUMERIC, 4)                      AS atr_14d,

    -- Tendances
    ROUND(ld.sma_20d::NUMERIC, 4)                      AS sma_20d,
    ROUND(ld.sma_50d::NUMERIC, 4)                      AS sma_50d,
    ROUND(ld.sma_200d::NUMERIC, 4)                     AS sma_200d,
    ROUND(ld.pct_above_sma50::NUMERIC, 2)              AS pct_above_sma50,

    -- Performance
    ROUND(ld.perf_1m_pct::NUMERIC, 2)                  AS perf_1m_pct,
    ROUND(ld.perf_3m_pct::NUMERIC, 2)                  AS perf_3m_pct,

    -- ══ Tags de régime IA : pré-classifiés ══
    CASE
        WHEN ld.close_price > ld.sma_200d
         AND ld.sma_50d > ld.sma_200d THEN 'Bullish — Golden Cross'
        WHEN ld.close_price > ld.sma_50d THEN 'Bullish — Above SMA50'
        WHEN ld.close_price < ld.sma_200d
         AND ld.sma_50d < ld.sma_200d THEN 'Bearish — Death Cross'
        ELSE 'Bearish — Below SMA50'
    END                                                AS trend_regime,

    -- Filtre liquidité institutionnel
    CASE
        WHEN ld.adv_20d_usd >= 50000000  THEN 'Institutionnel'
        WHEN ld.adv_20d_usd >= 2000000   THEN 'Retail Eligible'
        WHEN ld.adv_20d_usd >= 500000    THEN 'Micro Cap'
        ELSE 'Illiquid'
    END                                                AS liquidity_class,

    CURRENT_TIMESTAMP                                  AS mart_refreshed_at

FROM active_instruments ref
JOIN latest_day ld ON ref.provider_tick = ld.symbol

-- ══ Filtre de qualité Gold : exclure les instruments illiquides ══
WHERE ld.adv_20d_usd >= 500000      -- min 500K$/jour de volume
  AND ld.atr_14d IS NOT NULL        -- assez d'historique pour ATR 14j
  AND ld.sma_50d IS NOT NULL        -- assez d'historique pour SMA 50j

DISTINCT ON vs ROW_NUMBER pour "dernier par groupe" en PostgreSQL

SELECT DISTINCT ON (symbol) est une extension PostgreSQL qui sélectionne la première ligne de chaque groupe selon l'ORDER BY. C'est souvent 2-5× plus rapide qu'un CTE avec ROW_NUMBER() ... WHERE rn = 1 sur de grands volumes, car PostgreSQL peut utiliser un index sur (symbol, trade_date DESC). Les deux approches sont correctes — DISTINCT ON est idiomatique pour "dernier enregistrement par partition".

Mart Sectoriel : Rotation de Secteurs

dbt SQL — models/gold/mart_sector_momentum.sql
{{ config(materialized='table', tags=['gold', 'sector_analysis']) }}

-- Agrège la performance par secteur pour détecter la rotation sectorielle
-- Alimentera les dashboards Evidence.dev de la Partie 5

SELECT
    sector,
    instrument_type,
    COUNT(DISTINCT isin)                               AS num_instruments,
    ROUND(AVG(perf_1m_pct)::NUMERIC, 2)               AS avg_perf_1m,
    ROUND(AVG(perf_3m_pct)::NUMERIC, 2)               AS avg_perf_3m,
    ROUND(AVG(realized_vol_20d)::NUMERIC, 4)           AS avg_vol_20d,
    ROUND(SUM(adv_20d_usd)::NUMERIC, 0)               AS total_sector_adv,
    ROUND(AVG(pct_above_sma50)::NUMERIC, 2)           AS avg_pct_above_sma50,

    -- Score momentum : combinaison performance 1M + momentum SMA50
    ROUND((
        COALESCE(AVG(perf_1m_pct), 0) * 0.6 +
        COALESCE(AVG(pct_above_sma50), 0) * 0.4
    )::NUMERIC, 2)                                     AS sector_momentum_score,

    -- Classement par momentum (utile pour les APIs MCP de la Partie 5)
    RANK() OVER (
        PARTITION BY instrument_type
        ORDER BY (
            COALESCE(AVG(perf_1m_pct), 0) * 0.6 +
            COALESCE(AVG(pct_above_sma50), 0) * 0.4
        ) DESC
    )                                                  AS momentum_rank,

    CURRENT_DATE                                       AS as_of_date

FROM {{ ref('mart_strategy_universe') }}
WHERE sector IS NOT NULL
GROUP BY sector, instrument_type
Orchestration avec Nomad

Orchestrer dbt avec HashiCorp Nomad

Comme pour les pipelines d'ingestion de la Partie 2, nous utilisons Nomad pour orchestrer les runs dbt. Nomad gère le cycle de vie complet : injection des credentials de base de données depuis Vault, séquençage des étapes (Bronze → Silver → Snapshots → Tests → Gold), et retry automatique en cas d'échec.

Pourquoi Nomad pour dbt et pas un scheduler dbt natif ?

dbt Cloud propose un scheduler intégré, mais il n'a pas accès à notre infrastructure on-premise (PostgreSQL en réseau privé, Vault pour les secrets). Le scheduler dbt open source est basique. Nomad s'intègre nativement avec Vault (injection de secrets), le réseau privé, et les alertes Prometheus — et nous l'utilisons déjà pour les taps de la Partie 2.

Un seul système d'orchestration pour toute la stack : les taps d'ingestion (periodic batch) et les runs dbt (periodic batch) sont visibles dans la même UI Nomad, avec les mêmes patterns de logs et d'alertes.

HCL — jobs/dbt-nightly.nomad (job Nomad pour dbt)
job "dbt-nightly" {
  datacenters = ["dc1"]
  type        = "batch"
  namespace   = "securities-master"

  # Run chaque nuit à 02:00 UTC, après la fin des ingestions (Partie 2)
  periodic {
    crons            = ["0 2 * * *"]
    prohibit_overlap = true   # Ne pas lancer un nouveau run si le précédent tourne encore
    time_zone        = "UTC"
  }

  group "transforms" {
    count = 1

    # 4 Go de RAM pour les window functions PostgreSQL volumineuses
    resources {
      cpu    = 2000   # MHz
      memory = 4096   # MB
    }

    task "dbt-run" {
      driver = "docker"

      config {
        image   = "registry.internal/securities/dbt:1.7.0"
        command = "/bin/sh"
        args    = ["-c", "/app/run_pipeline.sh"]
      }

      # Injection sécurisée des credentials depuis Vault
      template {
        destination = "local/dbt_env"
        env         = true
        data        = <
bash — run_pipeline.sh (script d'orchestration dbt séquentiel)
#!/bin/bash
set -euo pipefail    # Arrêt immédiat sur erreur

echo "=== dbt Securities Master Pipeline — $(date -u) ==="

# ── Étape 1 : Tester les sources (raw_zone) ──────────────────────────────────
echo "→ [1/5] Test des sources raw_zone"
dbt test --select "source:raw_zone" --profiles-dir /local

# ── Étape 2 : Construire la couche Bronze (vues) ─────────────────────────────
echo "→ [2/5] Build Bronze (vues JSON)"
dbt run --select "tag:bronze" --profiles-dir /local

# ── Étape 3 : Build Silver (modèles incrémentiels) ───────────────────────────
echo "→ [3/5] Build Silver (incrémentiel)"
dbt run --select "tag:silver" --profiles-dir /local

# ── Étape 4 : Snapshots SCD Type 2 ───────────────────────────────────────────
echo "→ [4/5] Snapshots SCD2"
dbt snapshot --profiles-dir /local

# ── Étape 5 : Tests de qualité Silver ────────────────────────────────────────
echo "→ [5/5] Tests qualité Silver"
dbt test --select "tag:silver" \
         --store-failures \                   # Stocker les lignes en échec dans la DB
         --profiles-dir /local

# ── Étape 6 : Build Gold (uniquement si les tests Silver passent) ─────────────
echo "→ [6/6] Build Gold (marts IA)"
dbt run --select "tag:gold" --profiles-dir /local

# ── Étape 7 : Tests Gold ─────────────────────────────────────────────────────
echo "→ [7/7] Tests Gold"
dbt test --select "tag:gold" --profiles-dir /local

echo "✅ Pipeline dbt terminé avec succès — $(date -u)"

--store-failures : diagnostics en base de données

Avec --store-failures, dbt ne se contente pas d'indiquer qu'un test a échoué — il stocke les lignes en défaut dans un schéma dédié (dbt_test__audit). Après une alerte, vous pouvez directement requêter dbt_test__audit.not_null_fct_market_data_daily_symbol pour voir exactement quels instruments ont déclenché l'échec. Inestimable pour le debugging sur un système de production.

Graphe de Lineage dbt — du source raw au mart Gold

Points Clés de la Partie 3

  • Vues Bronze découplent le pipeline des changements de schéma API. Le pattern CTE + WHERE rn = 1 (pas QUALIFY) déduplique correctement en PostgreSQL.
  • Modèles incrémentiels avec fenêtre de 3 jours : 400× moins de données traitées par run. Le partitionnement PostgreSQL est défini au niveau DDL (Partie 1), pas dans la config dbt.
  • dbt Snapshots automatisent SCD Type 2 : détecter les mutations par hash, fermer l'ancienne version, insérer la nouvelle — sans code SQL manuel.
  • Tests dbt comme firewall de qualité : 8 tests couvrant nulls, types, cohérence OHLCV, unicité. Avec --store-failures, les violations sont stockées pour investigation.
  • Marts Gold pré-calculent ATR, SMA, volatilité réalisée, et régimes de tendance. Un LLM ou un dashboard BI accède aux données en millisecondes sans fenêtres complexes.
  • Nomad orchestre dbt séquentiellement (Bronze → Silver → Snapshots → Tests → Gold) avec injection des credentials Vault et alertes Slack en cas d'échec.

Les données sont maintenant propres, validées, et historisées. Mais la math est encore fausse. Si Apple fait un split 4-pour-1 demain, notre graphe affichera un crash de 75%. Il faut ajuster les prix historiques.

🏢

Partie 4 — Corporate Actions & Mathématiques d'Ajustement

Splits, dividendes, fusions, spin-offs : ajuster les prix historiques pour que vos backtests restent mathématiquement corrects.

Auto-Évaluation

Quiz : Maîtrisez-vous l'Architecture Médaillon ?

Cliquez sur chaque question pour révéler la réponse.

Pourquoi la syntaxe QUALIFY ROW_NUMBER() OVER (...) = 1 est-elle invalide en PostgreSQL ?
QUALIFY est une clause exclusive à BigQuery, Snowflake, et DuckDB. Elle permet de filtrer sur le résultat des fonctions de fenêtre dans la même requête. PostgreSQL (et SQL standard ANSI) ne supporte pas QUALIFY. Le pattern correct est une CTE intermédiaire : WITH deduped AS (SELECT *, ROW_NUMBER() OVER (...) AS rn FROM ...) SELECT * FROM deduped WHERE rn = 1. Utiliser QUALIFY dans un modèle dbt PostgreSQL provoque une erreur de syntaxe au runtime.
Quelle est la différence entre les opérateurs JSONB -> et ->> en PostgreSQL ?
-> retourne un objet JSONB (avec les guillemets JSON préservés, ex: "42.5" reste de type jsonb). ->> retourne une chaîne TEXT, ce qui permet le cast direct : (payload->>'price')::NUMERIC. Pour les casts numériques, utilisez toujours ->>. Pour accéder à des objets imbriqués que vous voulez encore manipuler avec des opérateurs JSON, utilisez ->. Pour les tableaux JSON, jsonb_array_element_text(payload->'key', index) retourne directement le texte de l'élément.
Pourquoi utiliser une fenêtre de "lookback" de 3 jours au lieu de WHERE trade_date > MAX(trade_date) dans un modèle incrémentiel ?
Les données financières arrivent avec du retard et sont corrigées rétroactivement. Yahoo Finance peut corriger ses prix ajustés des dividendes 2-3 jours après le paiement. Les exchanges peuvent envoyer des volumes révisés le lendemain de la séance. Avec > MAX(trade_date), ces corrections ne seraient jamais ingérées. Le lookback de 3 jours garantit qu'on retraite les derniers jours — et comme unique_key est défini, dbt génère un vrai upsert : les lignes existantes sont mises à jour, les nouvelles sont insérées. Coût : traiter ~3 jours au lieu de 1, soit toujours 400× moins qu'un rebuild complet.
Pourquoi ne faut-il PAS mettre partition_by={'field': 'trade_date', 'data_type': 'date'} dans la config dbt pour PostgreSQL ?
Cette option de configuration dbt est exclusive à BigQuery. Sur PostgreSQL, elle est soit silencieusement ignorée soit provoque des erreurs selon la version de l'adaptateur dbt-postgres. Le partitionnement PostgreSQL (PARTITION BY RANGE (trade_date)) est une propriété DDL définie à la création de la table — ce qui est fait en Partie 1 via le schéma SQL initial. dbt insère ensuite dans les partitions automatiquement sans aucune configuration supplémentaire dans le modèle. Pour BigQuery, l'option dbt est nécessaire car c'est dbt qui génère le DDL PARTITION BY. Les deux systèmes ont des mécanismes fondamentalement différents.
Comment dbt Snapshot détecte-t-il qu'un enregistrement a changé avec la stratégie check ?
dbt calcule un hash MD5 des colonnes listées dans check_cols. À chaque run de dbt snapshot, il compare ce hash pour chaque ligne entrante avec le hash stocké de la version active (où dbt_valid_to IS NULL). Si les hashes diffèrent (une colonne a changé) : dbt émet un UPDATE pour fermer l'ancienne ligne (dbt_valid_to = NOW()) puis un INSERT pour la nouvelle version (dbt_valid_from = NOW(), dbt_valid_to = NULL). Si les hashes sont identiques, rien ne se passe. Si une clé unique disparaît et que invalidate_hard_deletes=true, la ligne est fermée avec la date du run.
Quelle est la différence entre DISTINCT ON (col) et ROW_NUMBER() OVER (...) WHERE rn = 1 en PostgreSQL ?
Les deux sélectionnent la première ligne de chaque groupe, mais avec des performances différentes. DISTINCT ON est une extension PostgreSQL-native qui peut utiliser un index B-tree sur (partition_col, order_col DESC) directement — très efficace. ROW_NUMBER() via CTE est du SQL standard ANSI, portable sur BigQuery/Snowflake, mais nécessite un scan complet + tri avant le filtre. Pour du code PostgreSQL pur en production (couche Gold), DISTINCT ON est préférable. Pour du code qui doit rester portable entre moteurs, utilisez le pattern CTE avec ROW_NUMBER().
Pourquoi Nomad orchestre-t-il les runs dbt au lieu d'utiliser dbt Cloud ou un cron simple ?
Trois raisons critiques : (1) Accès réseau privé — notre PostgreSQL est sur un réseau interne inaccessible depuis dbt Cloud. (2) Gestion des secrets Vault — Nomad injecte les credentials de base de données depuis HashiCorp Vault à l'exécution, sans jamais les écrire dans des fichiers de configuration. Un cron simple ne supporte pas cette intégration. (3) Uniformité de la stack — les taps d'ingestion (Partie 2) et les runs dbt utilisent le même scheduler Nomad, avec les mêmes patterns de logs, d'alertes Prometheus, et de retry — une seule UI pour superviser toute la pipeline.
Quelle est l'utilité du post_hook="ANALYZE {{ this }}" dans la config du mart Gold ?
PostgreSQL maintient des statistiques de distribution des données pour l'optimiseur de requêtes. Après un rebuild complet d'une table (matérialisation table), les statistiques sont obsolètes. L'optimiseur peut choisir de mauvais plans de requête (ex: Seq Scan au lieu d'Index Scan) car il ne sait pas encore que la distribution a changé. La commande ANALYZE met à jour ces statistiques en échantillonnant la table. En l'ajoutant comme post_hook dans dbt, elle s'exécute automatiquement après chaque rebuild du mart Gold — garantissant des plans de requête optimaux dès la mise en production.