ML Text Classification for Trading

Objectif: Utiliser le NLP (Natural Language Processing) pour analyser le sentiment des news financières et générer des signaux de trading.

Stratégie

  1. Collection de données: Récupérer les headlines de news financières
  2. Feature extraction: Bag-of-words, TF-IDF pour transformer le texte en vecteurs
  3. Classification: Entraîner un classifieur (Naive Bayes, SVM, ou Logistic Regression)
  4. Hybridation: Combiner le sentiment avec des indicateurs techniques
  5. Trading: Long sur les actions avec sentiment positif + confirmations techniques

Prérequis

  • sklearn: pip install scikit-learn
  • nltk: pip install nltk (optionnel pour preprocessing avancé)
  • Compréhension basique du NLP (tokenization, stopwords, stemming)

Durée estimée: 45 minutes

# Initialisation de QuantBook
from AlgorithmImports import *

qb = QuantBook()

# Période d'analyse
qb.SetStartDate(2020, 1, 1)
qb.SetEndDate(2024, 12, 31)

# NB : SetStartDate/SetEndDate sont les bookends DEMANDÉS, pas la fenêtre calculée.
# qb.History(span) s'ancre à qb.Time (=StartDate dans un QuantBook frais) et regarde
# en arrière -- la fenêtre réellement calculée est divulguée dans la cellule History.
print(f"Période demandée (bookends): {qb.StartDate.date()} à {qb.EndDate.date()} (fenêtre effective calculée ci-dessous)")
Période demandée (bookends): 2020-01-01 à 2024-12-31 (fenêtre effective calculée ci-dessous)

1. Ajout des Actifs et Récupération des Données

# Ajout des actions tech
tickers = ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA"]
symbols = {}

for ticker in tickers:
    symbols[ticker] = qb.AddEquity(ticker, Resolution.DAILY).Symbol

print(f"{len(symbols)} actions ajoutées")
5 actions ajoutées

Pivot des séries de prix et de volumes en DataFrame large, avec remapping des colonnes Symbol → ticker pour ML-TextClassification.

# Récupération des données historiques
history = qb.History(list(symbols.values()), 365*2, Resolution.Daily)

# Traitement des données
closes = history['close'].unstack(level=0)
volumes = history['volume'].unstack(level=0)

# Remap columns from Lean Symbol objects to simple ticker strings
ticker_map = {symbols[t]: t for t in tickers}
closes = closes.rename(columns=ticker_map)
volumes = volumes.rename(columns=ticker_map)

# Keep only tickers that have data in the DataFrame
available = [t for t in tickers if t in closes.columns]
closes = closes[available]
volumes = volumes[available]
tickers = available

print(f"Shape des données: {closes.shape}")
print(f"Tickers disponibles: {tickers}")
# Fenêtre effective (règle #8772 window-honesty, recette #8776) : qb.History(span) est
# ancrée à qb.Time, qui dans un QuantBook frais vaut StartDate (2020-01-01) -- donc le
# lookback 365*2 (730 barres) finit VERS StartDate, pas vers EndDate. La période réellement
# calculée est ~2 ans AVANT StartDate, non la plage 2020-2024 des bookends ci-dessus.
print(f"Fenêtre effective: {closes.index[0].date()} à {closes.index[-1].date()} ({len(closes)} barres, calculée non annoncée)")
closes.head()
Shape des données: (730, 5)
Tickers disponibles: ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'NVDA']
Fenêtre effective: 2017-02-07 à 2019-12-31 (730 barres, calculée non annoncée)
symbol AAPL MSFT GOOGL AMZN NVDA
time
2017-02-07 16:00:00 32.8825 63.43 41.4615 40.6250 2.9783
2017-02-08 16:00:00 33.0100 63.34 41.4940 40.9855 2.9653
2017-02-09 16:00:00 33.1050 64.06 41.5030 41.0680 2.9095
2017-02-10 16:00:00 33.0300 64.00 41.7425 41.3730 2.8405
2017-02-13 16:00:00 33.3225 64.72 41.9480 41.8265 2.7095

2. Simulation de News Headlines

Note: En production, vous utiliseriez une API de news réelles (Bloomberg, Reuters, NewsAPI). Ici, nous simulons des headlines basés sur l’action des prix.

def simulate_news_headlines(ticker, prices, volumes):
    """Simule des headlines basés sur l'action des prix/volume.
    
    En production, remplacez par une API de news réelles.
    """
    headlines = []
    
    # Calculer les changements récents
    returns = prices.pct_change()
    vol_changes = volumes.pct_change()
    
    # Mots-clés positifs et négatifs
    positive_words = ['surges', 'rallies', 'gains', 'jumps', 'soars', 'climbs',
                     'strengthens', 'advances', 'rises', 'buys', 'bullish']
    negative_words = ['plummets', 'drops', 'declines', 'falls', 'slides',
                     'weakens', 'retreats', 'sells', 'bearish', 'losses']
    neutral_words = ['trades', 'holds', 'steady', 'flat', 'mixed', 'unchanged']
    
    for i in range(len(prices)):
        if i == 0:
            headlines.append(f"{ticker} market opens steady")
            continue
        
        ret = returns.iloc[i]
        vol_change = vol_changes.iloc[i] if i < len(vol_changes) else 0
        
        # Générer un headline basé sur l'action
        if ret > 0.03:
            if vol_change > 0.2:
                headlines.append(f"{ticker} surges on heavy volume")
            else:
                headlines.append(f"{ticker} rallies strongly, gains {ret*100:.1f}%")
        elif ret > 0.01:
            headlines.append(f"{ticker} gains ground, up {ret*100:.1f}%")
        elif ret > -0.01:
            headlines.append(f"{ticker} trades mixed in volatile session")
        elif ret > -0.03:
            headlines.append(f"{ticker} declines {abs(ret)*100:.1f}% on profit taking")
        else:
            if vol_change > 0.2:
                headlines.append(f"{ticker} plummets on high volume, down {abs(ret)*100:.1f}%")
            else:
                headlines.append(f"{ticker} drops sharply, loses {abs(ret)*100:.1f}%")
    
    return headlines

# Exemple avec AAPL
aapl_headlines = simulate_news_headlines(
    'AAPL', closes['AAPL'].iloc[:60], volumes['AAPL'].iloc[:60]
)

print("Exemples de headlines simulés:")
for i, h in enumerate(aapl_headlines[-10:]):
    print(f"{i+1}. {h}")
Exemples de headlines simulés:
1. AAPL gains ground, up 1.3%
2. AAPL trades mixed in volatile session
3. AAPL trades mixed in volatile session
4. AAPL trades mixed in volatile session
5. AAPL trades mixed in volatile session
6. AAPL trades mixed in volatile session
7. AAPL trades mixed in volatile session
8. AAPL gains ground, up 2.0%
9. AAPL trades mixed in volatile session
10. AAPL trades mixed in volatile session

3. Prétraitement du Texte (NLP Preprocessing)

import re
from collections import Counter

def preprocess_text(text):
    """Prétraite le texte: minuscules, suppression ponctuation."""
    # Minuscules
    text = text.lower()
    # Supprimer ponctuation
    text = re.sub(r'[^\w\s]', '', text)
    return text

def tokenize(text):
    """Tokenise le texte en mots."""
    return text.split()

def remove_stopwords(tokens, stopwords=None):
    """Supprime les mots courants (stopwords)."""
    if stopwords is None:
        stopwords = {'on', 'in', 'at', 'the', 'a', 'an', 'and', 'or', 'but', 'for'}
    return [t for t in tokens if t not in stopwords]

# Exemple de prétraitement
headline = "AAPL surges on heavy volume, gains 3.2%"
processed = preprocess_text(headline)
tokens = tokenize(processed)
filtered = remove_stopwords(tokens)

print(f"Original: {headline}")
print(f"Prétraité: {processed}")
print(f"Tokens: {tokens}")
print(f"Filtré: {filtered}")
Original: AAPL surges on heavy volume, gains 3.2%
Prétraité: aapl surges on heavy volume gains 32
Tokens: ['aapl', 'surges', 'on', 'heavy', 'volume', 'gains', '32']
Filtré: ['aapl', 'surges', 'heavy', 'volume', 'gains', '32']

4. Feature Extraction - Bag of Words

def create_vocabulary(headlines, max_features=1000):
    """Crée un vocabulaire à partir des headlines."""
    all_words = []
    
    for headline in headlines:
        processed = preprocess_text(headline)
        tokens = tokenize(processed)
        filtered = remove_stopwords(tokens)
        all_words.extend(filtered)
    
    # Compter les fréquences
    word_counts = Counter(all_words)
    
    # Retourner les mots les plus fréquents
    vocabulary = {word: idx for idx, (word, _) in enumerate(word_counts.most_common(max_features))}
    
    return vocabulary

def headline_to_bow(headline, vocabulary):
    """Convertit un headline en vecteur bag-of-words."""
    processed = preprocess_text(headline)
    tokens = tokenize(processed)
    filtered = remove_stopwords(tokens)
    
    # Créer vecteur binaire
    vector = np.zeros(len(vocabulary))
    for word in filtered:
        if word in vocabulary:
            vector[vocabulary[word]] = 1
    
    return vector

# Créer vocabulaire
all_headlines = []
for ticker in tickers:
    ticker_headlines = simulate_news_headlines(ticker, closes[ticker].iloc[:60], volumes[ticker].iloc[:60])
    all_headlines.extend(ticker_headlines)

vocabulary = create_vocabulary(all_headlines, max_features=100)

print(f"Taille du vocabulaire: {len(vocabulary)}")
print(f"\nMots les plus fréquents: {list(vocabulary.keys())[:20]}")
Taille du vocabulaire: 54

Mots les plus fréquents: ['trades', 'mixed', 'volatile', 'session', 'aapl', 'msft', 'googl', 'amzn', 'nvda', 'gains', 'ground', 'up', 'declines', 'profit', 'taking', '11', '13', 'market', 'opens', 'steady']

5. Préparation des Données d’Entraînement

def prepare_training_data(tickers, closes, vocabulary, window=5):
    """Prépare les données X (features) et y (targets)."""
    X = []
    y = []
    
    for ticker in tickers:
        # Simuler headlines
        headlines = simulate_news_headlines(ticker, closes[ticker], volumes[ticker])
        
        # Pour chaque headline (sauf les derniers)
        for i in range(window, len(headlines) - 1):
            # Features: bag-of-words des N derniers headlines
            recent_headlines = headlines[i-window:i]
            features = np.zeros(len(vocabulary))
            
            for h in recent_headlines:
                features += headline_to_bow(h, vocabulary)
            
            # Target: direction du prix le jour suivant
            current_price = closes[ticker].iloc[i]
            next_price = closes[ticker].iloc[i + 1]
            target = 1 if next_price > current_price else 0
            
            X.append(features)
            y.append(target)
    
    return np.array(X), np.array(y)

# Préparer les données
X, y = prepare_training_data(tickers, closes, vocabulary)

print(f"Features shape: {X.shape}")
print(f"Targets shape: {y.shape}")
print(f"Distribution des targets: {np.bincount(y)}")
print(f"Ratio positifs/négatifs: {y.sum() / len(y):.2%}")
Features shape: (3620, 54)
Targets shape: (3620,)
Distribution des targets: [1646 1974]
Ratio positifs/négatifs: 54.53%

6. Entraînement du Modèle de Classification

from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import MultinomialNB
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix

# Split train/test
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42, stratify=y
)

print(f"Train set: {X_train.shape[0]} échantillons")
print(f"Test set: {X_test.shape[0]} échantillons")
Train set: 2534 échantillons
Test set: 1086 échantillons

Le classifieur Naive Bayes est entraîné sur le corpus financier pour classer les textes selon leur sentiment (positif/négatif).

# Modèle 1: Naive Bayes
nb_model = MultinomialNB()
nb_model.fit(X_train + 1, y_train)  # +1 pour éviter les valeurs négatives

nb_pred = nb_model.predict(X_test + 1)
nb_accuracy = (nb_pred == y_test).mean()

print(f"Naive Bayes Accuracy: {nb_accuracy:.2%}")
print(f"\nRapport de classification:")
print(classification_report(y_test, nb_pred, target_names=['Down', 'Up']))
Naive Bayes Accuracy: 51.29%

Rapport de classification:
              precision    recall  f1-score   support

        Down       0.44      0.28      0.34       494
          Up       0.54      0.71      0.61       592

    accuracy                           0.51      1086
   macro avg       0.49      0.49      0.48      1086
weighted avg       0.50      0.51      0.49      1086

La régression logistique est entraînée (max_iter=1000) pour classifier les textes financiers et comparer ses performances avec Naive Bayes.

# Modèle 2: Logistic Regression
lr_model = LogisticRegression(max_iter=1000, random_state=42)
lr_model.fit(X_train, y_train)

lr_pred = lr_model.predict(X_test)
lr_accuracy = (lr_pred == y_test).mean()

print(f"Logistic Regression Accuracy: {lr_accuracy:.2%}")
print(f"\nRapport de classification:")
print(classification_report(y_test, lr_pred, target_names=['Down', 'Up']))
Logistic Regression Accuracy: 49.54%

Rapport de classification:
              precision    recall  f1-score   support

        Down       0.39      0.20      0.27       494
          Up       0.53      0.74      0.62       592

    accuracy                           0.50      1086
   macro avg       0.46      0.47      0.44      1086
weighted avg       0.47      0.50      0.46      1086

Comparaison des modèles Naive Bayes et Logistic Regression par probabilités prédites pour les signaux de ML-TextClassification.

# Comparaison des modèles
results = []

for name, model in [('Naive Bayes', nb_model), ('Logistic Regression', lr_model)]:
    pred_proba = model.predict_proba(X_test + 1 if name == 'Naive Bayes' else X_test)[:, 1]
    
    results.append({
        'Model': name,
        'Accuracy': (model.predict(X_test + 1 if name == 'Naive Bayes' else X_test) == y_test).mean(),
        'Avg_Prob_Up': pred_proba.mean()
    })

results_df = pd.DataFrame(results)
print(results_df)
                 Model  Accuracy  Avg_Prob_Up
0          Naive Bayes  0.512891     0.543860
1  Logistic Regression  0.495396     0.544084

7. Analyse du Sentiment par Action

def get_sentiment_score(ticker, model, closes, volumes, vocabulary, window=5):
    """Calcule un score de sentiment moyen pour une action."""
    # closes/volumes: DataFrame complet (cellule sentiment) OU Series single-ticker fenetree
    # (cellule backtest, window_closes = closes[ticker].iloc[i-60:i]). Normaliser pour eviter le
    # KeyError d'une indexation de Series par le label 'ticker'. Preserve la semantique de fenetre
    # (headlines[-window:] = 5 derniers headlines de la fenetre, pas de la serie globale).
    price_series = closes[ticker] if hasattr(closes, "columns") and ticker in closes.columns else closes
    vol_series = volumes[ticker] if hasattr(volumes, "columns") and ticker in volumes.columns else volumes
    # Simuler headlines récents
    headlines = simulate_news_headlines(ticker, price_series, vol_series)
    recent_headlines = headlines[-window:]
    
    # Agréger les features
    features = np.zeros(len(vocabulary))
    for h in recent_headlines:
        features += headline_to_bow(h, vocabulary)
    
    # Prédire la probabilité de hausse
    features = features.reshape(1, -1)
    prob = model.predict_proba(features + 1)[0][1]
    
    return prob

# Analyser le sentiment pour chaque action
sentiment_scores = {}

for ticker in tickers:
    sentiment = get_sentiment_score(ticker, nb_model, closes, volumes, vocabulary)
    sentiment_scores[ticker] = sentiment

print("Scores de sentiment actuels (probabilité de hausse):")
for ticker, score in sorted(sentiment_scores.items(), key=lambda x: x[1], reverse=True):
    print(f"{ticker}: {score:.2%}")
Scores de sentiment actuels (probabilité de hausse):
MSFT: 67.81%
AMZN: 59.82%
AAPL: 56.11%
NVDA: 53.94%
GOOGL: 49.09%

8. Modèle Hybride: Sentiment + Indicateurs Techniques

def calculate_technical_features(prices):
    """Calcule des indicateurs techniques."""
    # RSI
    delta = prices.diff()
    gain = (delta.where(delta > 0, 0)).rolling(14).mean()
    loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
    rs = gain / loss
    rsi = 100 - (100 / (1 + rs))
    
    # EMA ratio
    ema20 = prices.ewm(span=20).mean()
    ema50 = prices.ewm(span=50).mean()
    ema_ratio = ema20.iloc[-1] / ema50.iloc[-1]
    
    # Momentum
    momentum = (prices.iloc[-1] / prices.iloc[-5] - 1) if len(prices) >= 5 else 0
    
    return {
        'rsi': rsi.iloc[-1],
        'ema_ratio': ema_ratio,
        'momentum': momentum
    }

def hybrid_score(ticker, sentiment, tech_features, weights={'sentiment': 0.5, 'rsi': 0.2, 'ema': 0.15, 'mom': 0.15}):
    """Calcule un score hybride combinant sentiment et technique."""
    # Score RSI (surventu = positif)
    rsi_score = 1 if tech_features['rsi'] < 30 else 0 if tech_features['rsi'] > 70 else 0.5
    
    # Score EMA ratio
    ema_score = 1 if tech_features['ema_ratio'] > 1 else 0
    
    # Score momentum
    mom_score = 1 if tech_features['momentum'] > 0 else 0
    
    # Score hybride pondéré
    total = (
        sentiment * weights['sentiment'] +
        rsi_score * weights['rsi'] +
        ema_score * weights['ema'] +
        mom_score * weights['mom']
    )
    
    return total

# Calculer les scores hybrides
hybrid_scores = {}

for ticker in tickers:
    sentiment = sentiment_scores[ticker]
    tech = calculate_technical_features(closes[ticker])
    
    hybrid = hybrid_score(ticker, sentiment, tech)
    hybrid_scores[ticker] = {
        'sentiment': sentiment,
        'rsi': tech['rsi'],
        'ema_ratio': tech['ema_ratio'],
        'momentum': tech['momentum'],
        'hybrid': hybrid
    }

# Afficher les résultats
hybrid_df = pd.DataFrame(hybrid_scores).T
hybrid_df = hybrid_df.sort_values('hybrid', ascending=False)
print("Scores hybrides (triés par score total):")
print(hybrid_df.round(3))
Scores hybrides (triés par score total):
       sentiment     rsi  ema_ratio  momentum  hybrid
MSFT       0.678  78.101      1.031     0.002   0.639
AMZN       0.598  79.069      1.009     0.033   0.599
AAPL       0.561  95.124      1.054     0.033   0.581
GOOGL      0.491  48.000      1.022    -0.004   0.495
NVDA       0.539  79.031      1.060    -0.014   0.420

9. Backtest de la Stratégie de Trading

def backtest_strategy(closes, volumes, vocabulary, model, 
                     rebalance_freq=5, sentiment_threshold=0.6):
    """Backtest la stratégie de trading basée sur le sentiment."""
    
    # Date de début (après période d'entraînement)
    start_idx = 100
    portfolio_value = 100000
    positions = {}
    
    trades = []
    
    for i in range(start_idx, len(closes[tickers[0]]) - 1, rebalance_freq):
        current_date = closes.index[i]
        
        # Calculer les scores pour chaque action
        scores = {}
        for ticker in tickers:
            # Sentiment sur la fenêtre glissante
            window_closes = closes[ticker].iloc[i-60:i]
            window_volumes = volumes[ticker].iloc[i-60:i]
            
            if len(window_closes) < 60:
                continue
            
            sentiment = get_sentiment_score(ticker, model, window_closes, window_volumes, vocabulary)
            tech = calculate_technical_features(window_closes)
            hybrid = hybrid_score(ticker, sentiment, tech)
            
            scores[ticker] = hybrid
        
        # Sélectionner les meilleures actions
        sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        
        # Vendre toutes les positions
        for ticker, qty in positions.items():
            sell_price = closes[ticker].iloc[i]
            portfolio_value += qty * sell_price
            trades.append({
                'date': current_date,
                'action': 'SELL',
                'ticker': ticker,
                'price': sell_price,
                'qty': qty
            })
        
        positions = {}
        
        # Acheter les 3 meilleures actions (si score > seuil)
        position_size = portfolio_value / 3
        count = 0
        
        for ticker, score in sorted_scores:
            if score > sentiment_threshold and count < 3:
                buy_price = closes[ticker].iloc[i]
                qty = position_size / buy_price
                positions[ticker] = qty
                portfolio_value -= qty * buy_price
                
                trades.append({
                    'date': current_date,
                    'action': 'BUY',
                    'ticker': ticker,
                    'price': buy_price,
                    'score': score
                })
                count += 1
    
    # Valeur finale du portefeuille
    final_value = portfolio_value
    for ticker, qty in positions.items():
        final_value += qty * closes[ticker].iloc[-1]
    
    return {
        'initial_value': 100000,
        'final_value': final_value,
        'return': (final_value - 100000) / 100000,
        'trades': trades
    }

# Exécuter le backtest
backtest_results = backtest_strategy(closes, volumes, vocabulary, nb_model)

print(f"\nRésultats du Backtest:")
print(f"Valeur initiale: ${backtest_results['initial_value']:,.2f}")
print(f"Valeur finale: ${backtest_results['final_value']:,.2f}")
print(f"Return total: {backtest_results['return']:.2%}")
print(f"Nombre de trades: {len(backtest_results['trades'])}")
# Baseline buy-and-hold équipondéré sur la même fenêtre / le même univers
# (recette ai-01 c.45, #8777 CHANGES_REQUESTED) : sans ce comparateur, le +77% isolé
# laisse croire que le classifieur de sentiment "marche", alors que l'accuracy est
# ~52% (≈ hasard). Le rendement vient de la direction cumulative + du beta du panier
# tech sur la fenêtre, pas du signal de sentiment.
bh = (closes.ffill().iloc[-1] / closes.ffill().iloc[0] - 1).mean()
print(f"Buy & hold (équipondéré, même univers/fenêtre) : {bh:.2%}")
print(f"Écart stratégie - buy&hold                  : {backtest_results['return'] - bh:+.2%}")

Résultats du Backtest:
Valeur initiale: $100,000.00
Valeur finale: $178,368.67
Return total: 78.37%
Nombre de trades: 382
Buy & hold (équipondéré, même univers/fenêtre) : 111.67%
Écart stratégie - buy&hold                  : -33.30%

10. Améliorations Possibles

NLP Avancé

  • TF-IDF: Pondération par importance des mots
  • Word Embeddings: Word2Vec, GloVe pour capturer le contexte
  • Transformer Models: BERT, RoBERTa finetunés sur les news financières

Sources de Données

  • APIs de news: Bloomberg, Reuters, NewsAPI, Alpha Vantage
  • Social media: Twitter/X, StockTwits sentiment
  • SEC filings: Analyse des rapports 10-K, 10-Q

Modèles

  • Deep Learning: LSTM/GRU pour séquences temporelles de textes
  • Attention mechanisms: Pour identifier les mots clés importants
  • Ensemble: Combiner plusieurs modèles de NLP

Features Additionnelles

  • Sentiment temporel: Évolution du sentiment sur N jours
  • Entity recognition: Identifier les entreprises/produits mentionnés
  • Topic modeling: LDA pour identifier les thèmes des news
Retour au sommet