Building a Securities Master — Part 5 de 6

Interface IA : MCP & Dashboards Evidence.dev

Un LLM ne peut pas ingérer 50 millions de lignes PostgreSQL. Nous connectons les LLMs au Securities Master via un serveur MCP Go, avec des dashboards Evidence.dev pour la supervision humaine.

Model Context Protocol LLM Integration Evidence.dev Gouvernance IA
Securities Master 5/6
MCP — Introduction Serveur MCP Go Taxonomie des Outils Détection de Régime Evidence.dev Gouvernance IA Quiz

Notre Securities Master contient 60+ millions de barres OHLCV ajustées, des snapshots SCD2 de 15 000 instruments, et des marts Gold pré-calculés. Un LLM (Claude, GPT-4, Gemini) ne peut pas ingérer cette volumétrie dans son contexte. La solution : exposer le Securities Master comme un ensemble d'outils structurés que le LLM peut appeler on-demand via le Model Context Protocol (MCP).

Qu'est-ce que le Model Context Protocol (MCP) ?

MCP est un protocole ouvert développé par Anthropic (2024) qui standardise la façon dont les LLMs interagissent avec des systèmes externes. Au lieu que le LLM génère du SQL arbitraire (risque d'injection, accès non contrôlé), il appelle des outils MCP prédéfinis avec un schéma strict.

Architecture : le LLM ↔ MCP Client (dans Claude Desktop/Claude API) ↔ MCP Server (notre Go app) ↔ PostgreSQL (notre Securities Master). Le serveur MCP contrôle exactement quelles données sont exposées et avec quels filtres de sécurité.

Exemple d'interaction LLM ↔ MCP Securities Master
🤖 LLM : "Trouve les 10 meilleures actions momentum du secteur technologie avec un ADV > 50M$"
→ Tool call: get_momentum_candidates({sector: "Technology", min_adv_usd: 50000000, limit: 10})
← Tool result: [{symbol:"NVDA", close:875.4, perf_1m:+18.2%, sma_regime:"Bullish — Golden Cross", adv:2.1B}, ...]
🤖 LLM : "Sur la base de ces données, voici une allocation suggérée avec un sizing de Kelly fractionnaire..."
Architecture MCP — du LLM au Securities Master
Serveur MCP en Go

Implémentation du Serveur MCP

Le serveur MCP est un binaire Go qui écoute sur stdin/stdout (protocole JSON-RPC 2.0 via stdio transport). Claude Desktop ou un client MCP personnalisé lance ce processus et communique via des messages JSON structurés.

mcp-securities/ ├── cmd/ │ └── server/ │ └── main.go # Point d'entrée, configuration DB ├── internal/ │ ├── db/ │ │ └── postgres.go # Pool de connexions pgxpool │ ├── tools/ │ │ ├── universe.go # get_strategy_universe, get_instrument │ │ ├── market_data.go # get_price_history, get_ohlcv │ │ ├── regime.go # detect_market_regime │ │ ├── screener.go # screen_momentum, screen_breakout │ │ └── corporate.go # get_corporate_actions │ └── mcp/ │ ├── server.go # Boucle JSON-RPC stdio │ ├── registry.go # Registre des outils + schémas │ └── types.go # Types MCP (Tool, ToolCall, Result) ├── go.mod └── Dockerfile
go — internal/mcp/types.go (types MCP JSON-RPC)
package mcp

// Conformité avec la spec MCP v1.0 (Anthropic)

// Tool décrit un outil MCP exposé au LLM
type Tool struct {
    Name        string      `json:"name"`
    Description string      `json:"description"`
    InputSchema InputSchema `json:"inputSchema"`
}

// InputSchema est un JSON Schema décrivant les paramètres de l'outil
type InputSchema struct {
    Type       string              `json:"type"`        // "object"
    Properties map[string]Property `json:"properties"`
    Required   []string            `json:"required,omitempty"`
}

type Property struct {
    Type        string   `json:"type"`
    Description string   `json:"description"`
    Enum        []string `json:"enum,omitempty"`
    Minimum     *float64 `json:"minimum,omitempty"`
    Maximum     *float64 `json:"maximum,omitempty"`
}

// ToolCall est reçu du LLM quand il appelle un outil
type ToolCall struct {
    Name      string                 `json:"name"`
    Arguments map[string]interface{} `json:"arguments"`
}

// ToolResult est renvoyé au LLM après exécution
type ToolResult struct {
    Content []ContentBlock `json:"content"`
    IsError bool           `json:"isError,omitempty"`
}

type ContentBlock struct {
    Type string `json:"type"` // "text"
    Text string `json:"text"`
}
go — internal/mcp/server.go (boucle JSON-RPC stdio)
package mcp

import (
    "bufio"
    "encoding/json"
    "fmt"
    "os"
)

// JSONRPCRequest : message entrant du client MCP
type JSONRPCRequest struct {
    JSONRPC string          `json:"jsonrpc"`
    ID      interface{}     `json:"id"`
    Method  string          `json:"method"`
    Params  json.RawMessage `json:"params"`
}

// JSONRPCResponse : message sortant vers le client MCP
type JSONRPCResponse struct {
    JSONRPC string      `json:"jsonrpc"`
    ID      interface{} `json:"id"`
    Result  interface{} `json:"result,omitempty"`
    Error   *RPCError   `json:"error,omitempty"`
}

type RPCError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

// Server gère la boucle de communication MCP via stdio
type Server struct {
    registry *Registry
    scanner  *bufio.Scanner
    encoder  *json.Encoder
}

func NewServer(reg *Registry) *Server {
    return &Server{
        registry: reg,
        scanner:  bufio.NewScanner(os.Stdin),
        encoder:  json.NewEncoder(os.Stdout),
    }
}

func (s *Server) Run() error {
    for s.scanner.Scan() {
        line := s.scanner.Bytes()

        var req JSONRPCRequest
        if err := json.Unmarshal(line, &req); err != nil {
            s.writeError(nil, -32700, "Parse error")
            continue
        }

        switch req.Method {
        // Handshake : le client MCP découvre les capacités du serveur
        case "initialize":
            s.writeResult(req.ID, map[string]interface{}{
                "protocolVersion": "2024-11-05",
                "serverInfo": map[string]string{
                    "name":    "securities-master-mcp",
                    "version": "1.0.0",
                },
                "capabilities": map[string]interface{}{
                    "tools": map[string]bool{"listChanged": false},
                },
            })

        // Liste des outils disponibles
        case "tools/list":
            s.writeResult(req.ID, map[string]interface{}{
                "tools": s.registry.ListTools(),
            })

        // Exécution d'un outil spécifique
        case "tools/call":
            var p struct {
                Name      string                 `json:"name"`
                Arguments map[string]interface{} `json:"arguments"`
            }
            if err := json.Unmarshal(req.Params, &p); err != nil {
                s.writeError(req.ID, -32602, "Invalid params")
                continue
            }

            result, err := s.registry.Execute(p.Name, p.Arguments)
            if err != nil {
                s.writeResult(req.ID, ToolResult{
                    IsError: true,
                    Content: []ContentBlock{{Type: "text", Text: err.Error()}},
                })
                continue
            }
            s.writeResult(req.ID, result)

        default:
            s.writeError(req.ID, -32601, fmt.Sprintf("Method not found: %s", req.Method))
        }
    }
    return s.scanner.Err()
}

func (s *Server) writeResult(id interface{}, result interface{}) {
    _ = s.encoder.Encode(JSONRPCResponse{JSONRPC: "2.0", ID: id, Result: result})
}

func (s *Server) writeError(id interface{}, code int, msg string) {
    _ = s.encoder.Encode(JSONRPCResponse{JSONRPC: "2.0", ID: id, Error: &RPCError{Code: code, Message: msg}})
}
go — internal/tools/screener.go (outil de screener momentum)
package tools

import (
    "context"
    "encoding/json"
    "fmt"

    "github.com/jackc/pgx/v5/pgxpool"
    "securities-mcp/internal/mcp"
)

type ScreenerTool struct {
    db *pgxpool.Pool
}

// Definition retourne le schéma MCP de l'outil (exposé au LLM)
func (t *ScreenerTool) Definition() mcp.Tool {
    min0  := float64(0)
    max100 := float64(100)
    return mcp.Tool{
        Name:        "screen_momentum_candidates",
        Description: "Trouve les actions avec le meilleur momentum prix selon les critères donnés. " +
                     "Utilise les marts Gold pré-calculés (SMA, volatilité réalisée, ADV). " +
                     "Retourne maximum 'limit' résultats triés par score momentum décroissant.",
        InputSchema: mcp.InputSchema{
            Type: "object",
            Properties: map[string]mcp.Property{
                "sector": {
                    Type:        "string",
                    Description: "Filtrer par secteur GICS (ex: 'Technology', 'Healthcare', 'Energy'). Vide = tous secteurs.",
                },
                "trend_regime": {
                    Type:        "string",
                    Description: "Filtrer par régime de tendance.",
                    Enum:        []string{"Bullish — Golden Cross", "Bullish — Above SMA50", "Bearish — Death Cross", "Bearish — Below SMA50"},
                },
                "min_adv_usd": {
                    Type:        "number",
                    Description: "ADV minimum en USD (Average Daily Volume). Ex: 50000000 pour 50M$.",
                    Minimum:     &min0,
                },
                "min_perf_1m_pct": {
                    Type:        "number",
                    Description: "Performance minimale sur 1 mois en %. Ex: 5 pour +5%.",
                },
                "max_vol_20d": {
                    Type:        "number",
                    Description: "Volatilité réalisée 20j maximale (annualisée). Ex: 0.4 pour 40%.",
                    Minimum:     &min0,
                    Maximum:     &max100,
                },
                "limit": {
                    Type:        "number",
                    Description: "Nombre maximum de résultats. Défaut: 10, maximum: 50.",
                    Minimum:     &min0,
                },
            },
            Required: []string{},
        },
    }
}

// Execute exécute l'outil et retourne un ToolResult
func (t *ScreenerTool) Execute(ctx context.Context, args map[string]interface{}) (mcp.ToolResult, error) {
    // Parsing des arguments avec valeurs par défaut
    sector, _    := args["sector"].(string)
    regime, _    := args["trend_regime"].(string)
    minADV       := getFloat(args, "min_adv_usd", 0)
    minPerf      := getFloat(args, "min_perf_1m_pct", -100)
    maxVol       := getFloat(args, "max_vol_20d", 999)
    limit        := int(getFloat(args, "limit", 10))

    if limit > 50 { limit = 50 }   // Sécurité : limiter les résultats

    // Construction de la requête SQL paramétrée (pas de string interpolation!)
    query := `
        SELECT
            symbol,
            company_name,
            sector,
            ROUND(close_price::NUMERIC, 2) AS close_price,
            ROUND(adv_20d_usd::NUMERIC / 1e6, 1) AS adv_20d_m,
            ROUND(perf_1m_pct::NUMERIC, 2) AS perf_1m_pct,
            ROUND(perf_3m_pct::NUMERIC, 2) AS perf_3m_pct,
            ROUND(realized_vol_20d::NUMERIC * 100, 1) AS vol_20d_pct,
            trend_regime,
            liquidity_class
        FROM gold_zone.mart_strategy_universe
        WHERE 1=1
          AND ($1::TEXT = '' OR sector = $1)
          AND ($2::TEXT = '' OR trend_regime = $2)
          AND adv_20d_usd >= $3
          AND perf_1m_pct >= $4
          AND realized_vol_20d <= $5
        ORDER BY perf_1m_pct DESC, adv_20d_usd DESC
        LIMIT $6
    `

    rows, err := t.db.Query(ctx, query, sector, regime, minADV, minPerf, maxVol, limit)
    if err != nil {
        return mcp.ToolResult{}, fmt.Errorf("database query failed: %w", err)
    }
    defer rows.Close()

    type Candidate struct {
        Symbol       string  `json:"symbol"`
        Name         string  `json:"name"`
        Sector       string  `json:"sector"`
        Price        float64 `json:"price_usd"`
        ADV_M        float64 `json:"adv_20d_million_usd"`
        Perf1M       float64 `json:"perf_1m_pct"`
        Perf3M       float64 `json:"perf_3m_pct"`
        Vol20D       float64 `json:"realized_vol_20d_pct"`
        TrendRegime  string  `json:"trend_regime"`
        Liquidity    string  `json:"liquidity_class"`
    }

    var candidates []Candidate
    for rows.Next() {
        var c Candidate
        if err := rows.Scan(&c.Symbol, &c.Name, &c.Sector, &c.Price, &c.ADV_M,
                             &c.Perf1M, &c.Perf3M, &c.Vol20D, &c.TrendRegime, &c.Liquidity); err != nil {
            continue
        }
        candidates = append(candidates, c)
    }

    result, _ := json.MarshalIndent(map[string]interface{}{
        "candidates": candidates,
        "count":      len(candidates),
        "filters_applied": map[string]interface{}{
            "sector": sector, "trend_regime": regime,
            "min_adv_usd": minADV, "min_perf_1m_pct": minPerf,
        },
    }, "", "  ")

    return mcp.ToolResult{
        Content: []mcp.ContentBlock{{Type: "text", Text: string(result)}},
    }, nil
}

func getFloat(args map[string]interface{}, key string, def float64) float64 {
    if v, ok := args[key]; ok {
        if f, ok := v.(float64); ok {
            return f
        }
    }
    return def
}
Taxonomie des Outils MCP

Concevoir la Taxonomie des Outils

L'architecture des outils MCP est critique : trop génériques et le LLM génère des requêtes coûteuses ; trop spécialisés et il manque de flexibilité. Notre taxonomie sépare les outils en trois familles selon leur impact.

Lecture (Read-Only)

get_instrument_profile — données de référence d'un titre
get_price_history — OHLCV ajustés sur une période
screen_momentum_candidates — screener Gold mart
get_sector_rotation — performance sectorielle
get_corporate_actions — historique splits/dividendes

Calcul (Compute)

detect_market_regime — régime global (Bull/Bear/Transition)
calculate_correlation_matrix — corrélation N×N sur fenêtre
estimate_kelly_fraction — sizing optimal par setup
backtest_simple_strategy — backtest paramétrique rapide

Écriture (Guardrails)

create_watchlist — créer une liste de suivi
add_alert — configurer une alerte prix
⚠️ Aucun outil d'ordre de trading — le système de trading opère en Partie 6 avec des guardrails stricts séparés.

Outil MCPSource de DonnéesLatence cibleRésultats max
screen_momentum_candidatesGold mart_strategy_universe< 50ms50
get_price_historySilver fct_market_data_daily< 200ms252 barres (1 an)
get_instrument_profileSilver dim_instruments_current< 20ms1 instrument
detect_market_regimeGold mart_strategy_universe< 100ms1 objet régime
get_sector_rotationGold mart_sector_momentum< 50ms11 secteurs
calculate_correlation_matrixSilver (window fn temps-réel)< 2sMatrix 20×20 max
Détection de Régime de Marché

Détecter le Régime de Marché depuis le Gold Mart

Avant qu'un LLM puisse recommander des stratégies, il doit comprendre le contexte macro. Nous exposons une détection de régime en temps réel qui interroge le mart Gold agrégé — pas de calcul à la volée, réponse en <100ms.

go — internal/tools/regime.go
package tools

import (
    "context"
    "encoding/json"
    "fmt"

    "github.com/jackc/pgx/v5/pgxpool"
    "securities-mcp/internal/mcp"
)

type MarketRegime struct {
    Regime           string  `json:"regime"`             // "Risk-On", "Risk-Off", "Transition"
    BullishPct       float64 `json:"bullish_pct"`        // % actions au-dessus SMA50
    GoldenCrossPct   float64 `json:"golden_cross_pct"`   // % avec Golden Cross
    AvgVol20D        float64 `json:"avg_realized_vol_pct"`
    MedianADV_B      float64 `json:"median_adv_billion"`
    Breadth          string  `json:"market_breadth"`     // "Expanding", "Narrowing", "Weak"
    TopSectors       []string `json:"top_sectors"`
    BottomSectors    []string `json:"bottom_sectors"`
    AsOfDate         string  `json:"as_of_date"`
}

func (t *RegimeTool) Execute(ctx context.Context, args map[string]interface{}) (mcp.ToolResult, error) {
    // Requête agrégée sur le Gold Mart — aucun calcul de fenêtre temps-réel
    row := t.db.QueryRow(ctx, `
        WITH universe AS (
            SELECT
                trend_regime,
                realized_vol_20d,
                adv_20d_usd,
                sector,
                perf_1m_pct
            FROM gold_zone.mart_strategy_universe
            WHERE instrument_type = 'EQUITY'
        ),
        regime_stats AS (
            SELECT
                COUNT(*) FILTER (WHERE trend_regime LIKE 'Bullish%')::FLOAT / COUNT(*) * 100
                    AS bullish_pct,
                COUNT(*) FILTER (WHERE trend_regime = 'Bullish — Golden Cross')::FLOAT / COUNT(*) * 100
                    AS golden_cross_pct,
                AVG(realized_vol_20d) * 100
                    AS avg_vol_20d_pct,
                PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY adv_20d_usd) / 1e9
                    AS median_adv_b
            FROM universe
        )
        SELECT
            bullish_pct, golden_cross_pct,
            avg_vol_20d_pct, median_adv_b,
            CURRENT_DATE::TEXT AS as_of_date
        FROM regime_stats
    `)

    var r MarketRegime
    r.AsOfDate = "unknown"
    if err := row.Scan(&r.BullishPct, &r.GoldenCrossPct, &r.AvgVol20D, &r.MedianADV_B, &r.AsOfDate); err != nil {
        return mcp.ToolResult{}, fmt.Errorf("regime query failed: %w", err)
    }

    // Détermination du régime par règles métier
    switch {
    case r.BullishPct >= 70 && r.GoldenCrossPct >= 40:
        r.Regime = "Risk-On — Strong Bull Market"
        r.Breadth = "Expanding"
    case r.BullishPct >= 55:
        r.Regime = "Risk-On — Moderate Uptrend"
        r.Breadth = "Expanding"
    case r.BullishPct >= 40:
        r.Regime = "Transition — Mixed Signals"
        r.Breadth = "Narrowing"
    case r.BullishPct >= 25:
        r.Regime = "Risk-Off — Early Bear"
        r.Breadth = "Weak"
    default:
        r.Regime = "Risk-Off — Bear Market"
        r.Breadth = "Weak"
    }

    // Top/Bottom secteurs
    sectorRows, _ := t.db.Query(ctx, `
        SELECT sector, sector_momentum_score
        FROM gold_zone.mart_sector_momentum
        WHERE instrument_type = 'EQUITY' AND sector IS NOT NULL
        ORDER BY momentum_rank ASC
        LIMIT 3
    `)
    defer sectorRows.Close()
    for sectorRows.Next() {
        var sector string
        var score float64
        if err := sectorRows.Scan(§or, &score); err == nil {
            r.TopSectors = append(r.TopSectors, sector)
        }
    }

    result, _ := json.MarshalIndent(r, "", "  ")
    return mcp.ToolResult{
        Content: []mcp.ContentBlock{{Type: "text", Text: string(result)}},
    }, nil
}
Evidence.dev — BI-as-Code

Dashboards BI-as-Code avec Evidence.dev

Pendant que l'agent IA opère de façon autonome, les portfolio managers exigent une supervision humaine. Evidence.dev transforme du Markdown avec des requêtes SQL directement en dashboards interactifs. Tout est versionné avec le code — pas de Tableau, pas de Metabase, juste du Markdown + SQL + Git.

Qu'est-ce qu'Evidence.dev ?

Evidence.dev est un framework open-source qui génère des dashboards statiques à partir de fichiers Markdown contenant des blocs de requêtes SQL. Chaque fichier .md dans pages/ devient une page de dashboard. Les requêtes SQL s'exécutent contre votre PostgreSQL à la compilation, les résultats sont intégrés dans un site statique. Le dashboard final peut être hébergé sur GitHub Pages, Netlify, ou un serveur interne — sans serveur backend.

bash — Installation et configuration Evidence.dev
# Initialiser un projet Evidence.dev
npx degit evidence-dev/template securities-dashboards
cd securities-dashboards
npm install

# Configurer la connexion PostgreSQL (sources.yaml)
cat > sources/securities.yaml << EOF
name: securities_master
type: postgres
host: ${POSTGRES_HOST}
port: ${POSTGRES_PORT}
database: securities_master
user: ${POSTGRES_READONLY_USER}    # Utilisateur READ-ONLY uniquement
password: ${POSTGRES_PASSWORD}
EOF

# Développement local avec hot-reload
npm run dev   # → http://localhost:3000
markdown — pages/universe-overview.md (dashboard univers)
---
title: "Securities Universe — Vue d'ensemble"
description: "Tableau de bord de l'univers d'investissement du Securities Master"
---

# Universe Overview

## Régime de Marché Actuel

```sql regime_summary
SELECT
    trend_regime,
    COUNT(*) AS num_instruments,
    ROUND(AVG(perf_1m_pct)::NUMERIC, 2) AS avg_perf_1m,
    ROUND(AVG(adv_20d_usd / 1e6)::NUMERIC, 0) AS avg_adv_m
FROM gold_zone.mart_strategy_universe
WHERE instrument_type = 'EQUITY'
GROUP BY trend_regime
ORDER BY num_instruments DESC
```

<BarChart
    data={regime_summary}
    x="trend_regime"
    y="num_instruments"
    colorPalette={["#0d9488","#f59e0b","#ef4444","#64748b"]}
    title="Distribution des régimes de tendance"
/>

## Top 20 Momentum Candidates

```sql top_momentum
SELECT
    symbol,
    company_name,
    sector,
    ROUND(close_price::NUMERIC, 2) AS price,
    ROUND(adv_20d_usd / 1e6, 1) AS "ADV (M$)",
    ROUND(perf_1m_pct::NUMERIC, 2) AS "Perf 1M %",
    ROUND(perf_3m_pct::NUMERIC, 2) AS "Perf 3M %",
    ROUND(realized_vol_20d * 100::NUMERIC, 1) AS "Vol 20D %",
    trend_regime AS "Régime"
FROM gold_zone.mart_strategy_universe
WHERE instrument_type = 'EQUITY'
  AND trend_regime LIKE 'Bullish%'
  AND adv_20d_usd >= 50e6
ORDER BY perf_1m_pct DESC
LIMIT 20
```

<DataTable data={top_momentum} search=true rows=10 />

## Rotation Sectorielle

```sql sector_rotation
SELECT
    sector,
    num_instruments,
    avg_perf_1m AS "Perf 1M %",
    avg_perf_3m AS "Perf 3M %",
    sector_momentum_score AS "Score Momentum",
    momentum_rank AS "Rang"
FROM gold_zone.mart_sector_momentum
WHERE instrument_type = 'EQUITY' AND sector IS NOT NULL
ORDER BY momentum_rank ASC
```

<BarChart
    data={sector_rotation}
    x="sector"
    y="Perf 1M %"
    title="Performance sectorielle 1 mois"
    colorPalette={["#0d9488"]}
/>
markdown — pages/corporate-actions-monitor.md (monitoring corporate actions)
---
title: "Corporate Actions Monitor"
---

# Corporate Actions Récentes

```sql recent_ca
SELECT
    symbol,
    isin,
    ex_date,
    ca_type AS "Type",
    CASE
        WHEN ca_type = 'SPLIT'    THEN 'Ratio: ' || split_ratio::TEXT
        WHEN ca_type = 'DIVIDEND' THEN 'Montant: $' || dividend_amount::TEXT
        ELSE description
    END AS details,
    provider_name AS "Source"
FROM raw_zone.corporate_actions
WHERE ex_date >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY ex_date DESC
LIMIT 50
```

<DataTable data={recent_ca} search=true rows=20 />

## Instruments avec Ajustements Récents

```sql adjusted_instruments
SELECT
    m.symbol,
    m.company_name,
    m.close_price,
    m.close_total_return_yahoo AS close_adj,
    m.cumulative_split_factor,
    m.combined_adjustment_factor,
    m.last_price_date
FROM gold_zone.mart_adjusted_prices m
WHERE m.cumulative_split_factor != 1.0
   OR m.combined_adjustment_factor < 0.99
ORDER BY m.last_price_date DESC
LIMIT 30
```

<DataTable data={adjusted_instruments} />
Distribution des régimes de tendance — Univers mondial (exemple)
Gouvernance IA

Guardrails et Gouvernance du Serveur MCP

Exposer une base de données financière à un LLM sans guardrails est dangereux. Voici les couches de protection implémentées dans notre serveur MCP.

go — Middleware de sécurité MCP (rate limiting + audit log)
// internal/mcp/middleware.go

// RateLimiter : evite que le LLM exécute trop de requêtes par minute
type RateLimiter struct {
    mu       sync.Mutex
    calls    map[string][]time.Time   // tool_name → timestamps
    limit    int                      // max appels par fenêtre
    window   time.Duration
}

func (rl *RateLimiter) Allow(toolName string) bool {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    now := time.Now()
    // Purger les timestamps anciens
    var recent []time.Time
    for _, t := range rl.calls[toolName] {
        if now.Sub(t) < rl.window {
            recent = append(recent, t)
        }
    }
    if len(recent) >= rl.limit {
        return false   // Rate limit atteint
    }
    rl.calls[toolName] = append(recent, now)
    return true
}

// AuditLogger : chaque appel d'outil est loggué pour conformité
type AuditLogger struct {
    db *pgxpool.Pool
}

func (al *AuditLogger) Log(ctx context.Context, entry AuditEntry) {
    // Log asynchrone pour ne pas bloquer la réponse
    go func() {
        _, _ = al.db.Exec(ctx, `
            INSERT INTO ops.mcp_audit_log
                (tool_name, arguments_json, result_rows, duration_ms, session_id, ts)
            VALUES ($1, $2, $3, $4, $5, NOW())
        `, entry.ToolName, entry.ArgsJSON, entry.ResultRows,
           entry.DurationMs, entry.SessionID)
    }()
}

type AuditEntry struct {
    ToolName   string
    ArgsJSON   string
    ResultRows int
    DurationMs int64
    SessionID  string
}

// ResultRowLimit : limite stricte sur le nombre de lignes retournées
// Un LLM n'a pas besoin de plus de 50 résultats — protège contre les dumps complets
const MaxResultRows = 50

// ReadOnlyUser : le serveur MCP se connecte avec un user PostgreSQL READ-ONLY
// CREATE USER mcp_readonly WITH PASSWORD '...';
// GRANT CONNECT ON DATABASE securities_master TO mcp_readonly;
// GRANT USAGE ON SCHEMA gold_zone, silver_zone TO mcp_readonly;
// GRANT SELECT ON ALL TABLES IN SCHEMA gold_zone TO mcp_readonly;
// REVOKE ALL ON SCHEMA raw_zone FROM mcp_readonly;  -- raw_zone: jamais exposé

Règle absolue : le serveur MCP ne transmet jamais d'ordres de trading

Notre serveur MCP expose uniquement des outils de lecture et d'analyse. Aucun outil ne permet de créer un ordre, de modifier une position, ou de déclencher une exécution. Le chemin entre l'analyse IA et l'exécution réelle passe obligatoirement par l'OMS (Order Management System) de la Partie 6, avec des guardrails séparés, des limites de risque, et une validation humaine.

Points Clés de la Partie 5

L'agent IA a maintenant accès à des données mathématiquement correctes via des outils sécurisés. Dans la Partie 6, nous fermons la boucle : comment ces signaux IA atteignent les marchés réels sans détruire le capital.

⚙️

Partie 6 — Opérations : OMS, Exécution & Monitoring

OMS adapter pattern, intégration Interactive Brokers, contrôles de risque, SLOs de monitoring et conclusion de la série.

Auto-Évaluation

Quiz : MCP & Interfaces IA

Quelle est la différence fondamentale entre un LLM qui génère du SQL directement et un LLM qui utilise MCP ?
Contrôle, sécurité, et prévisibilité. Un LLM générant du SQL arbitraire peut écrire des requêtes non optimisées (scans de table entière), accéder à des données sensibles (raw_zone), ou théoriquement générer des requêtes destructives (DROP, UPDATE). Avec MCP, le LLM appelle uniquement des outils prédéfinis avec des schémas stricts : paramètres validés, requêtes SQL paramétrées (pas d'injection), utilisateur PostgreSQL read-only, résultats limités. Le serveur MCP est une API contrôlée vers la base de données — le LLM ne "voit" jamais le SQL sous-jacent.
Pourquoi les requêtes SQL dans les outils MCP utilisent-elles des paramètres ($1, $2...) plutôt que de l'interpolation de chaînes ?
Pour prévenir les injections SQL (SQL Injection). Un LLM malicieux ou détourné (prompt injection) pourrait tenter de passer des valeurs comme ' OR 1=1 -- dans les arguments. Avec des requêtes paramétrées, PostgreSQL traite les paramètres comme des données et non comme du code SQL — l'injection devient impossible. C'est la même règle que pour tout code qui interagit avec une base de données : ne jamais construire des requêtes SQL par concatenation de chaînes avec des entrées utilisateurs (ou LLM).
Qu'est-ce que le "transport stdio" dans le Model Context Protocol ?
C'est le mécanisme de communication principal de MCP. Le client MCP (ex: Claude Desktop) lance le serveur MCP comme un processus enfant et communique via les flux stdin (entrée) et stdout (sortie) du processus. Les messages sont des objets JSON-RPC 2.0 délimités par des sauts de ligne. Avantages : pas de port réseau à gérer, pas de TLS à configurer, le serveur tourne localement sur la machine de l'utilisateur. Alternative : le transport SSE (Server-Sent Events) pour les serveurs MCP distants. Notre serveur Go utilise stdio, idéal pour une intégration desktop.
Pourquoi Evidence.dev peut-il héberger des dashboards sans backend, et quelles sont les limitations de cette approche ?
Evidence.dev génère un site statique à la compilation. Les requêtes SQL s'exécutent une seule fois lors du build (npm run build) — les résultats sont sérialisés en JSON dans les fichiers HTML/JS statiques. Le site résultant peut être hébergé sur n'importe quel CDN (GitHub Pages, Vercel, Netlify) sans backend. Limitations : les données ne sont "fraîches" qu'au moment du dernier build. Pour un dashboard mis à jour chaque nuit (après le pipeline dbt), on planifie le build Evidence.dev comme job Nomad périodique. Les données en temps réel (tick-by-tick) ne sont pas adaptées à cette approche.
Comment le rate limiting protège-t-il le Securities Master des appels excessifs d'un LLM ?
En limitant le nombre d'appels par outil par fenêtre temporelle. Un LLM en boucle (hallucination d'outil, prompt malicieux) pourrait appeler get_price_history des milliers de fois. Notre RateLimiter maintient un journal des timestamps des appels récents par outil. Si le nombre d'appels dans la fenêtre (ex: 60 secondes) dépasse le seuil (ex: 30 appels), le serveur retourne une erreur MCP standardisée (isError: true) sans exécuter la requête PostgreSQL. Combiné avec le pool de connexions pgxpool (max 10 connexions), cela protège la base de données des surcharges.
Securities Master 5/6