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.
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).
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é.
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.
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"`
}
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}})
}
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
}
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.
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
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
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 MCP | Source de Données | Latence cible | Résultats max |
|---|---|---|---|
screen_momentum_candidates | Gold mart_strategy_universe | < 50ms | 50 |
get_price_history | Silver fct_market_data_daily | < 200ms | 252 barres (1 an) |
get_instrument_profile | Silver dim_instruments_current | < 20ms | 1 instrument |
detect_market_regime | Gold mart_strategy_universe | < 100ms | 1 objet régime |
get_sector_rotation | Gold mart_sector_momentum | < 50ms | 11 secteurs |
calculate_correlation_matrix | Silver (window fn temps-réel) | < 2s | Matrix 20×20 max |
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.
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
}
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.
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.
# 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
---
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"]}
/>
---
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} />
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.
// 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é
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.
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.
' 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).
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.