diff --git a/ANALYSE_PARAMETRES_SCAN_SCALABLES.md b/ANALYSE_PARAMETRES_SCAN_SCALABLES.md new file mode 100644 index 00000000..8dffd5a5 --- /dev/null +++ b/ANALYSE_PARAMETRES_SCAN_SCALABLES.md @@ -0,0 +1,166 @@ +# 📊 Analyse des Paramètres du Scan de Scalabilité + +## 🔍 Paramètres Variables du Scan de Scalabilité + +D'après `core/scanner.py` (classe `ScalabilityScanner`), le scan retourne les paramètres suivants : + +| Paramètre | Type | Description | Source | +|-----------|------|-------------|--------| +| `price` | `float` | Prix actuel (dernier close) | `klines[-1]['close']` | +| `recentVolume` | `float` | Volume total des 5 dernières bougies 1m | `sum(volumes[-5:])` | +| `vol5` | `float` | Volatilité sur 5 périodes (écart-type normalisé en %) | `calculate_volatility(closes, 5)` | +| `vol15` | `float` | Volatilité sur 15 périodes (écart-type normalisé en %) | `calculate_volatility(closes, 15)` | +| `spread` | `float` | Spread en % entre best_bid et best_ask | `fetch_spread_data()` | +| `bookDepth` | `float` | Profondeur totale (somme des 5 premiers niveaux) | `fetch_spread_data()` | +| `bidVol` | `float` | Volume bid total | `fetch_spread_data()` | +| `askVol` | `float` | Volume ask total | `fetch_spread_data()` | +| `balanceScore` | `float` | Score d'équilibre bid/ask (0-1, 1.0 = équilibré) | `fetch_spread_data()` | +| `score` | `float` | Score de scalabilité final | `calculate_score()` | + +### 📐 Formule du Score de Scalabilité + +```python +vol_spread_ratio = vol5 / spread # Si spread > 0 et vol5 > 0 +norm_factor = 0.5 * (recentVolume / max_volume) + 0.5 * (bookDepth / max_depth) +balance_bonus = balanceScore # 0-1 +raw_score = vol_spread_ratio * log10(recentVolume + 1) * norm_factor * balance_bonus +``` + +--- + +## ✅ Présence dans le Schéma SQL + +### 📋 Table `scan_logs` + +| Paramètre Scan | Colonne SQL | Type | Statut | +|----------------|-------------|------|--------| +| `price` | `price` | `FLOAT NOT NULL` | ✅ **PRÉSENT** | +| `spread` | `spread_pct` | `FLOAT` | ✅ **PRÉSENT** | +| `bookDepth` | `book_depth` | `FLOAT` | ✅ **PRÉSENT** | +| `balanceScore` | `balance_score` | `FLOAT` | ✅ **PRÉSENT** | +| `bidVol` | `bid_vol` | `FLOAT` | ✅ **PRÉSENT** | +| `askVol` | `ask_vol` | `FLOAT` | ✅ **PRÉSENT** | +| `recentVolume` | - | - | ❌ **MANQUANT** | +| `vol5` | - | - | ❌ **MANQUANT** | +| `vol15` | - | - | ❌ **MANQUANT** | +| `score` | - | - | ❌ **MANQUANT** | + +**Note** : `orderbook_imbalance_ratio` est présent dans le schéma (calculé comme `bid_vol / ask_vol`), mais n'est pas directement retourné par le scan. + +--- + +### 📋 Table `trades` + +| Paramètre Scan | Colonne SQL | Type | Statut | +|----------------|-------------|------|--------| +| `spread` (entry) | `entry_spread_pct` | `FLOAT` | ✅ **PRÉSENT** | +| `balanceScore` (entry) | `entry_balance_score` | `FLOAT` | ✅ **PRÉSENT** | +| `bookDepth` (entry) | `entry_book_depth` | `FLOAT` | ✅ **PRÉSENT** | +| `bidVol` (entry) | `entry_bid_vol` | `FLOAT` | ✅ **PRÉSENT** | +| `askVol` (entry) | `entry_ask_vol` | `FLOAT` | ✅ **PRÉSENT** | +| `spread` (exit) | `exit_spread_pct` | `FLOAT` | ✅ **PRÉSENT** | +| `balanceScore` (exit) | `exit_balance_score` | `FLOAT` | ✅ **PRÉSENT** | +| `recentVolume` (entry/exit) | - | - | ❌ **MANQUANT** | +| `vol5` (entry/exit) | - | - | ❌ **MANQUANT** | +| `vol15` (entry/exit) | - | - | ❌ **MANQUANT** | +| `score` (entry) | - | - | ❌ **MANQUANT** | + +--- + +### 📋 Table `market_context` + +| Paramètre Scan | Colonne SQL | Type | Statut | +|----------------|-------------|------|--------| +| `spread` (moyenne) | `avg_spread` | `FLOAT` | ✅ **PRÉSENT** | +| `vol5` / `vol15` (moyenne) | `avg_volatility_1m` / `avg_volatility_5m` | `FLOAT` | ⚠️ **PARTIEL** (moyennes, pas valeurs individuelles) | +| `recentVolume` | - | - | ❌ **MANQUANT** | +| `score` | - | - | ❌ **MANQUANT** | + +--- + +## ❌ Paramètres Manquants dans le Schéma SQL + +### 1. **`recentVolume`** (Volume des 5 dernières bougies) +- **Utilisation** : Utilisé dans le calcul du score de scalabilité (`log10(recentVolume + 1)`) +- **Alternative existante** : `volume_1m` et `volume_ratio_1m` dans `scan_logs`, mais pas le volume spécifique des 5 dernières bougies +- **Impact** : ⚠️ **MOYEN** - Peut être approximé par `volume_1m`, mais pas exactement la même valeur + +### 2. **`vol5`** (Volatilité 5 périodes) +- **Utilisation** : Utilisé dans le calcul du score (`vol5 / spread`) +- **Alternative existante** : `atr_pct_1m` (ATR en %), mais ce n'est pas exactement la même métrique +- **Impact** : ⚠️ **MOYEN** - `atr_pct_1m` peut servir de proxy, mais `vol5` est un calcul spécifique (écart-type normalisé) + +### 3. **`vol15`** (Volatilité 15 périodes) +- **Utilisation** : Utilisé pour le calcul du score (potentiellement) +- **Alternative existante** : `atr_pct_5m` (ATR en %), mais ce n'est pas exactement la même métrique +- **Impact** : ⚠️ **FAIBLE** - Moins utilisé que `vol5` dans le calcul du score + +### 4. **`score`** (Score de scalabilité final) +- **Utilisation** : Score final utilisé pour classer les paires +- **Alternative existante** : Aucune +- **Impact** : ⚠️ **MOYEN** - Utile pour l'analyse ML (corrélation entre score de scalabilité et performance des trades) + +--- + +## 💡 Recommandations + +### Option 1 : Ajouter les Colonnes Manquantes (Recommandé) + +```sql +-- Pour scan_logs +ALTER TABLE scan_logs ADD COLUMN recent_volume FLOAT; +ALTER TABLE scan_logs ADD COLUMN vol5 FLOAT; +ALTER TABLE scan_logs ADD COLUMN vol15 FLOAT; +ALTER TABLE scan_logs ADD COLUMN scalability_score FLOAT; + +-- Pour trades (entry) +ALTER TABLE trades ADD COLUMN entry_recent_volume FLOAT; +ALTER TABLE trades ADD COLUMN entry_vol5 FLOAT; +ALTER TABLE trades ADD COLUMN entry_vol15 FLOAT; +ALTER TABLE trades ADD COLUMN entry_scalability_score FLOAT; + +-- Pour trades (exit) +ALTER TABLE trades ADD COLUMN exit_recent_volume FLOAT; +ALTER TABLE trades ADD COLUMN exit_vol5 FLOAT; +ALTER TABLE trades ADD COLUMN exit_vol15 FLOAT; +``` + +### Option 2 : Utiliser les Alternatives Existantes (Temporaire) + +- `recentVolume` → Utiliser `volume_1m` (approximation) +- `vol5` → Utiliser `atr_pct_1m` (proxy) +- `vol15` → Utiliser `atr_pct_5m` (proxy) +- `score` → Ne pas logger (perte d'information) + +⚠️ **Note** : Cette option entraîne une perte de précision pour l'analyse ML. + +--- + +## 📊 Résumé + +| Paramètre | Présent dans `scan_logs` | Présent dans `trades` | Action Recommandée | +|-----------|-------------------------|----------------------|-------------------| +| `price` | ✅ | ✅ (via `entry_price`) | ✅ OK | +| `spread` | ✅ (`spread_pct`) | ✅ (`entry_spread_pct`, `exit_spread_pct`) | ✅ OK | +| `bookDepth` | ✅ (`book_depth`) | ✅ (`entry_book_depth`) | ✅ OK | +| `balanceScore` | ✅ (`balance_score`) | ✅ (`entry_balance_score`, `exit_balance_score`) | ✅ OK | +| `bidVol` | ✅ (`bid_vol`) | ✅ (`entry_bid_vol`) | ✅ OK | +| `askVol` | ✅ (`ask_vol`) | ✅ (`entry_ask_vol`) | ✅ OK | +| `recentVolume` | ❌ | ❌ | ⚠️ **AJOUTER** | +| `vol5` | ❌ | ❌ | ⚠️ **AJOUTER** | +| `vol15` | ❌ | ❌ | ⚠️ **AJOUTER** (optionnel) | +| `score` | ❌ | ❌ | ⚠️ **AJOUTER** | + +--- + +## 🎯 Conclusion + +**7 paramètres sur 10 sont présents** dans le schéma SQL. + +**3 paramètres manquants** : +1. `recentVolume` (volume des 5 dernières bougies) +2. `vol5` (volatilité 5 périodes) +3. `score` (score de scalabilité final) + +**Recommandation** : Ajouter ces 3 colonnes à `scan_logs` et `trades` pour une analyse ML complète. + diff --git a/COMPARAISON_PARAMETRES_SCALABLES_SCHEMA.md b/COMPARAISON_PARAMETRES_SCALABLES_SCHEMA.md new file mode 100644 index 00000000..611f2243 --- /dev/null +++ b/COMPARAISON_PARAMETRES_SCALABLES_SCHEMA.md @@ -0,0 +1,213 @@ +# 📊 Comparaison : Paramètres Scalables vs Schéma SQL + +## ✅ Résumé + +**La plupart des paramètres variables provenant du scan des paires scalables sont présents dans le schéma SQL**, mais certains paramètres spécifiques au calcul du score de scalabilité ne sont pas stockés directement. + +--- + +## 📋 Tableau Comparatif + +| Paramètre Scan Scalables | Nom dans Schéma SQL | Table | Statut | Notes | +|--------------------------|---------------------|-------|--------|-------| +| **`spread`** | `spread_pct` | `scan_logs` | ✅ **PRÉSENT** | Ligne 80 | +| **`bookDepth`** | `book_depth` | `scan_logs` | ✅ **PRÉSENT** | Ligne 81 | +| **`balanceScore`** | `balance_score` | `scan_logs` | ✅ **PRÉSENT** | Ligne 82 | +| **`bidVol`** | `bid_vol` | `scan_logs` | ✅ **PRÉSENT** | Ligne 83 | +| **`askVol`** | `ask_vol` | `scan_logs` | ✅ **PRÉSENT** | Ligne 84 | +| **`orderbook_imbalance_ratio`** | `orderbook_imbalance_ratio` | `scan_logs` | ✅ **PRÉSENT** | Ligne 85 (calculé : bid_vol / ask_vol) | +| **`vol5`** | ❌ | - | ❌ **MANQUANT** | Volatilité 5 périodes (utilisée pour calculer le score) | +| **`vol15`** | ❌ | - | ❌ **MANQUANT** | Volatilité 15 périodes (utilisée pour calculer le score) | +| **`recentVolume`** | ❌ | - | ❌ **MANQUANT** | Volume des 5 dernières bougies (utilisé pour calculer le score) | +| **`score`** | ❌ | - | ❌ **MANQUANT** | Score de scalabilité final (utilisé pour classer les paires) | +| **`price`** | `price` | `scan_logs` | ✅ **PRÉSENT** | Ligne 79 | + +--- + +## 📍 Détails par Table + +### 1. **Table `scan_logs`** ✅ + +**Paramètres présents** : +```sql +-- Données marché (lignes 78-85) +price FLOAT NOT NULL, +spread_pct FLOAT, +book_depth FLOAT, +balance_score FLOAT, +bid_vol FLOAT, +ask_vol FLOAT, +orderbook_imbalance_ratio FLOAT, -- bid_vol / ask_vol +``` + +**Paramètres manquants** : +- `vol5` : Volatilité sur 5 périodes +- `vol15` : Volatilité sur 15 périodes +- `recentVolume` : Volume des 5 dernières bougies +- `score` : Score de scalabilité + +**Note** : Les volumes sont stockés via `volume_1m` et `volume_5m`, mais pas le `recentVolume` spécifique (somme des 5 dernières bougies). + +--- + +### 2. **Table `trades`** ✅ + +**Paramètres présents au moment de l'entrée** : +```sql +-- Score et autres (ligne 434-435) +entry_spread_pct FLOAT, +entry_balance_score FLOAT, + +-- Scalability data au entry (lignes 550-553) +entry_book_depth FLOAT, +entry_bid_vol FLOAT, +entry_ask_vol FLOAT, +entry_orderbook_imbalance FLOAT, +``` + +**Paramètres présents au moment de la sortie** : +```sql +-- Score et autres (lignes 474-475) +exit_spread_pct FLOAT, +exit_balance_score FLOAT, +``` + +**Paramètres manquants** : +- `entry_vol5` / `exit_vol5` : Volatilité 5 périodes +- `entry_vol15` / `exit_vol15` : Volatilité 15 périodes +- `entry_recentVolume` / `exit_recentVolume` : Volume récent +- `entry_scalability_score` / `exit_scalability_score` : Score de scalabilité + +--- + +### 3. **Table `market_context`** ⚠️ PARTIEL + +**Paramètres présents** : +```sql +-- Métriques globales (lignes 601-603) +avg_spread FLOAT, +avg_volatility_1m FLOAT, +avg_volatility_5m FLOAT, +``` + +**Note** : Ces valeurs sont des **moyennes globales** pour toutes les paires, pas des valeurs spécifiques par paire. + +--- + +## 🔍 Analyse des Paramètres Manquants + +### 1. **`vol5` et `vol15`** (Volatilité) + +**Impact** : ⚠️ **MOYEN** +- Utilisés pour calculer le score de scalabilité +- Pourraient être utiles pour l'analyse ML (corrélation volatilité/performance) +- **Alternative** : Les indicateurs ATR (`atr_pct_1m`, `atr_pct_5m`) sont déjà stockés et représentent la volatilité + +**Recommandation** : +- ✅ **Option 1** : Utiliser `atr_pct_1m` et `atr_pct_5m` comme proxy (déjà présents) +- ⚠️ **Option 2** : Ajouter `vol5` et `vol15` si besoin d'analyse spécifique + +--- + +### 2. **`recentVolume`** (Volume des 5 dernières bougies) + +**Impact** : ⚠️ **FAIBLE** +- Utilisé pour calculer le score de scalabilité +- **Alternative** : `volume_1m` et `volume_ratio_1m` sont déjà stockés + +**Recommandation** : +- ✅ **Option 1** : Utiliser `volume_1m` comme proxy (déjà présent) +- ⚠️ **Option 2** : Ajouter `recent_volume_5m` si besoin d'analyse spécifique + +--- + +### 3. **`score`** (Score de scalabilité) + +**Impact** : ⚠️ **FAIBLE** +- Utilisé uniquement pour classer les paires lors du scan +- **Pas nécessaire pour ML** : Le score est une métrique composite qui peut être recalculé +- **Alternative** : Les composants du score (`spread_pct`, `book_depth`, `balance_score`, `volume_ratio_1m`, `atr_pct_1m`) sont déjà stockés + +**Recommandation** : +- ✅ **Ne pas ajouter** : Le score peut être recalculé si nécessaire, et les features individuelles sont plus utiles pour ML + +--- + +## ✅ Conclusion + +### Paramètres Essentiels : **TOUS PRÉSENTS** ✅ + +Les paramètres **essentiels** pour l'analyse ML et le logging sont tous présents : +- ✅ `spread_pct` : Spread du carnet d'ordres +- ✅ `book_depth` : Profondeur du carnet +- ✅ `balance_score` : Équilibre bid/ask +- ✅ `bid_vol` / `ask_vol` : Volumes bid/ask +- ✅ `orderbook_imbalance_ratio` : Ratio d'équilibre + +### Paramètres Optionnels : **MANQUANTS** ⚠️ + +Les paramètres suivants ne sont **pas stockés directement** mais peuvent être **reconstruits ou remplacés** : +- ⚠️ `vol5` / `vol15` → **Alternative** : `atr_pct_1m` / `atr_pct_5m` (déjà présents) +- ⚠️ `recentVolume` → **Alternative** : `volume_1m` / `volume_ratio_1m` (déjà présents) +- ⚠️ `score` → **Alternative** : Peut être recalculé depuis les features stockées + +--- + +## 🎯 Recommandations + +### ✅ **Action Immédiate : AUCUNE** + +Le schéma SQL contient **tous les paramètres essentiels** pour : +- ✅ Logger les données de scalabilité +- ✅ Analyser la corrélation entre scalabilité et performance +- ✅ Entraîner des modèles ML + +### ⚠️ **Action Optionnelle : Ajouter `vol5` et `vol15`** + +Si vous souhaitez analyser spécifiquement la volatilité calculée par le scanner (différente de l'ATR), vous pouvez ajouter : + +```sql +-- Dans scan_logs +ALTER TABLE scan_logs ADD COLUMN vol5 FLOAT; +ALTER TABLE scan_logs ADD COLUMN vol15 FLOAT; +ALTER TABLE scan_logs ADD COLUMN recent_volume_5m FLOAT; + +-- Dans trades (optionnel) +ALTER TABLE trades ADD COLUMN entry_vol5 FLOAT; +ALTER TABLE trades ADD COLUMN entry_vol15 FLOAT; +ALTER TABLE trades ADD COLUMN exit_vol5 FLOAT; +ALTER TABLE trades ADD COLUMN exit_vol15 FLOAT; +``` + +**Mais ce n'est pas nécessaire** car : +- L'ATR (`atr_pct_1m`, `atr_pct_5m`) représente déjà la volatilité +- Le volume (`volume_1m`, `volume_ratio_1m`) représente déjà l'activité récente + +--- + +## 📊 Utilisation Actuelle + +D'après le code Python, les paramètres suivants sont **déjà loggés** : + +### Dans `scan_logs` : +```python +# core/postgresql_datalogger.py - log_scan() +spread_pct=scan_data.get('spread_pct'), +book_depth=scan_data.get('book_depth'), +balance_score=scan_data.get('balance_score'), +bid_vol=scan_data.get('bid_vol'), +ask_vol=scan_data.get('ask_vol'), +``` + +### Dans `trades` : +```python +# core/postgresql_datalogger.py - log_trade() +entry_spread_pct=entry_scalability.get('spread_pct'), +entry_book_depth=entry_scalability.get('depth'), +entry_balance_score=entry_scalability.get('balance'), +entry_bid_vol=entry_scalability.get('bidVol'), +entry_ask_vol=entry_scalability.get('askVol'), +``` + +**Conclusion** : ✅ **Tous les paramètres essentiels sont déjà loggés correctement !** + diff --git a/PARAMETRES_VARIABLES_SCAN_SCALABLES.md b/PARAMETRES_VARIABLES_SCAN_SCALABLES.md new file mode 100644 index 00000000..f2fc282e --- /dev/null +++ b/PARAMETRES_VARIABLES_SCAN_SCALABLES.md @@ -0,0 +1,239 @@ +# 📊 Paramètres Variables Provenant du Scan des Paires Scalables + +## Vue d'ensemble + +Le scan des paires scalables (`ScalabilityScanner.scan_top_pairs()`) calcule et retourne plusieurs paramètres variables pour chaque paire. Ces paramètres sont utilisés pour : +- Calculer un score de scalabilité +- Filtrer les meilleures paires pour le trading +- Estimer le slippage lors de l'ouverture de positions +- Logger les données dans PostgreSQL + +--- + +## 🔍 Paramètres Calculés par Paire + +### 1. **Données de Prix et Volume** + +| Paramètre | Type | Description | Source | +|-----------|------|-------------|--------| +| `symbol` | `str` | Symbole de la paire (ex: `SOL/USDT:USDT`) | Paramètre d'entrée | +| `price` | `float` | Prix actuel (dernier close) | `klines[-1][4]` (dernier close) | +| `recentVolume` | `float` | Volume total des 5 dernières bougies 1m | `sum(volumes[-5:])` | +| `vol5` | `float` | Volatilité sur 5 périodes (écart-type normalisé en %) | `calculate_volatility(closes, 5)` | +| `vol15` | `float` | Volatilité sur 15 périodes (écart-type normalisé en %) | `calculate_volatility(closes, 15)` | + +**Formule volatilité** : +```python +volatilité = (écart_type / moyenne) * 100 +``` + +--- + +### 2. **Données du Carnet d'Ordres (Orderbook)** + +Ces paramètres sont récupérés via `fetch_spread_data()` qui analyse les 5 meilleurs niveaux du carnet d'ordres : + +| Paramètre | Type | Description | Calcul | +|-----------|------|-------------|--------| +| `spread` | `float` | Spread en % entre best_bid et best_ask | `((best_ask - best_bid) / mid_price) * 100` | +| `bookDepth` | `float` | Profondeur totale du carnet (somme des 5 premiers niveaux) | `sum(bid_volumes) + sum(ask_volumes)` | +| `bidVol` | `float` | Volume total côté bid (achat) sur 5 niveaux | `sum(bids[:5][1])` | +| `askVol` | `float` | Volume total côté ask (vente) sur 5 niveaux | `sum(asks[:5][1])` | +| `balanceScore` | `float` | Score d'équilibre bid/ask (0-1) | `1 - (abs(bid_ask_ratio - 0.5) * 2)` | + +**Formule balanceScore** : +```python +bid_ask_ratio = bidVol / (bidVol + askVol) +balanceScore = 1 - (abs(bid_ask_ratio - 0.5) * 2) +# 1.0 = parfaitement équilibré (50/50) +# 0.0 = complètement déséquilibré (100/0 ou 0/100) +``` + +--- + +### 3. **Score de Scalabilité** + +| Paramètre | Type | Description | Calcul | +|-----------|------|-------------|--------| +| `score` | `float` | Score final de scalabilité (utilisé pour classer les paires) | `calculate_score(pair, max_volume, max_depth)` | + +**Formule du score** : +```python +# Ratio volatilité/spread +vol_spread_ratio = vol5 / spread + +# Facteur de normalisation (volume + depth) +norm_factor = 0.5 * (recentVolume / max_volume) + 0.5 * (bookDepth / max_depth) + +# Bonus balance +balance_bonus = balanceScore + +# Score brut +raw_score = vol_spread_ratio * log10(recentVolume + 1) * norm_factor * balance_bonus +``` + +**Filtres appliqués avant calcul du score** : +- `spread` doit être entre **0.001%** et **0.02%** +- `bookDepth` doit être **> 0** +- `recentVolume` doit être **≥ 100,000** +- `balanceScore` doit être **≥ TRADING_CONFIG['balance_score_min']** + +--- + +## 📋 Structure Complète d'une Paire Scalable + +```python +{ + # Identifiant + 'symbol': 'SOL/USDT:USDT', + + # Métadonnées + 'maker': 0.0, # Fee maker (0% pour paires sélectionnées) + 'taker': 0.0, # Fee taker (0% pour paires sélectionnées) + + # Prix et volume + 'price': 144.24, + 'recentVolume': 1250000.0, # Volume 5 dernières bougies + 'vol5': 0.85, # Volatilité 5 périodes (%) + 'vol15': 1.2, # Volatilité 15 périodes (%) + + # Carnet d'ordres + 'spread': 0.0069, # Spread en % + 'bookDepth': 103961.0, # Profondeur totale + 'bidVol': 58774.0, # Volume bid + 'askVol': 45187.0, # Volume ask + 'balanceScore': 0.869, # Score d'équilibre (0-1) + + # Score final + 'score': 12.45 # Score de scalabilité (utilisé pour classement) +} +``` + +--- + +## 🔄 Utilisation dans le Code + +### 1. **Stockage dans `app_state`** +```python +top_pairs = await scanner.scan_top_pairs(n=20) +app_state['top_pairs'] = top_pairs # Liste de 20 paires triées par score décroissant +``` + +### 2. **Récupération lors de l'ouverture de position** +```python +# Dans main.py ou scanner_loop.py +for pair in top_pairs: + if pair['symbol'] == symbol: + scalability_data = { + 'spread_pct': pair.get('spread', 0), + 'depth': pair.get('bookDepth', 0), + 'balance': pair.get('balanceScore', 1.0), + 'bidVol': pair.get('bidVol', 0), + 'askVol': pair.get('askVol', 0) + } +``` + +### 3. **Estimation du slippage** +```python +# Dans position_manager.py +slippage_pct = _estimate_slippage( + order_size=20.0, + spread_pct=scalability_data['spread_pct'], + depth=scalability_data['depth'], + balance_score=scalability_data['balance'] +) +``` + +### 4. **Logging dans PostgreSQL** +Ces paramètres sont loggés dans la table `trades` : +- `entry_spread_pct` : Spread au moment de l'entrée +- `entry_book_depth` : Profondeur du carnet à l'entrée +- `entry_balance_score` : Balance score à l'entrée +- `exit_spread_pct` : Spread au moment de la sortie +- `exit_book_depth` : Profondeur du carnet à la sortie +- `exit_balance_score` : Balance score à la sortie + +--- + +## ⚠️ Valeurs par Défaut en Cas d'Erreur + +Si le scan d'une paire échoue, les valeurs suivantes sont utilisées : +```python +{ + 'recentVolume': 0, + 'vol5': 0, + 'vol15': 0, + 'spread': float('nan'), + 'bookDepth': 0, + 'balanceScore': 0, + 'bidVol': 0, + 'askVol': 0, + 'price': 0 +} +``` + +--- + +## 📊 Fréquence de Mise à Jour + +- **Scan initial** : Au démarrage du bot +- **Refresh périodique** : Toutes les **90 secondes** (configurable via `scalability_refresh_interval`) +- **Trigger manuel** : Via commande WebSocket `refresh_scalability` + +--- + +## 🎯 Paramètres Utilisés pour le Calcul du Score + +Le score de scalabilité est calculé en utilisant : +1. **`vol5`** : Volatilité récente (plus élevée = mieux) +2. **`spread`** : Spread (plus faible = mieux) +3. **`recentVolume`** : Volume récent (plus élevé = mieux) +4. **`bookDepth`** : Profondeur du carnet (plus élevée = mieux) +5. **`balanceScore`** : Équilibre bid/ask (plus proche de 1.0 = mieux) + +**Formule complète** : +``` +score = (vol5 / spread) × log10(recentVolume + 1) × norm_factor × balanceScore + +où norm_factor = 0.5 × (recentVolume / max_volume) + 0.5 × (bookDepth / max_depth) +``` + +--- + +## 🔍 Exemple de Logs + +D'après vos logs récents : +``` +💹 DEBUG: Données brutes depuis top_pairs: + spread=0.006932168728980559, + bookDepth=103961.0, + balanceScore=0.8693067592654937, + bidVol=58774.0, + askVol=45187.0 +``` + +Ces valeurs sont récupérées depuis `top_pairs` et utilisées pour : +- Calculer le slippage estimé +- Logger dans PostgreSQL +- Décider si une position peut être ouverte + +--- + +## 📝 Notes Importantes + +1. **`spread` peut être `NaN`** : Si le carnet d'ordres est vide ou invalide +2. **`balanceScore` varie entre 0 et 1** : 1.0 = parfaitement équilibré +3. **`bookDepth` est en unités de base** : Pas normalisé, valeur brute +4. **`vol5` et `vol15` sont en pourcentage** : Volatilité normalisée +5. **Le score est recalculé à chaque scan** : Les paires peuvent changer de classement + +--- + +## 🔗 Fichiers Concernés + +- **`core/scanner.py`** : Calcul des paramètres +- **`main.py`** : Utilisation lors de l'ouverture de position +- **`core/position_manager.py`** : Estimation du slippage +- **`core/postgresql_datalogger.py`** : Logging dans PostgreSQL +- **`core/callbacks/scalability_refresh.py`** : Refresh périodique + diff --git a/core/analyzer.py b/core/analyzer.py index 4cf51f6a..dea8ab94 100644 --- a/core/analyzer.py +++ b/core/analyzer.py @@ -566,7 +566,16 @@ def build_indicators_dict(reason=None): if not has_swing: reason = f"Pas de structure swing: {direction} requis (HH/HL pour LONG, LH/LL pour SHORT)" if return_reason: - return {'reason': reason, 'symbol': symbol, 'timeframe': timeframe, 'direction': direction} + # 🔥 FIX: Retourner les indicateurs même si la structure swing est absente + result_dict = build_indicators_dict(reason) + result_dict.update({ + 'symbol': symbol, + 'timeframe': timeframe, + 'direction': direction, + 'long_score': long_score if use_weighted else None, + 'short_score': short_score if use_weighted else None + }) + return result_dict if DEBUG_ENABLED: logger.debug(f"{symbol} {timeframe}: {reason}") return None @@ -625,6 +634,8 @@ def build_indicators_dict(reason=None): 'signals': conditions, 'condition_types': condition_types, 'totalScore': final_score, + 'long_score': long_score if use_weighted else None, # 🔥 FIX: Ajouter long_score pour les fallbacks + 'short_score': short_score if use_weighted else None, # 🔥 FIX: Ajouter short_score pour les fallbacks 'min_score_required': min_score_required, 'timeframe': timeframe, 'volatility': atr / price if price > 0 else 0, @@ -926,7 +937,80 @@ async def analyze_pair( f"⚠️ {symbol} - Setup rejeté : Spread trop élevé " f"({spread_check['spread_pct']:.3f}% > {spread_check['max_allowed']:.3f}%)" ) - return None + # 🔥 FIX: Retourner un dict avec les indicateurs et un reason au lieu de None + # pour permettre le logging des indicateurs même si le setup est rejeté + # Construire indicators_1m et indicators_5m depuis analysis_1m et analysis_5m + indicators_1m_reject = {} + indicators_5m_reject = {} + if analysis_1m and not (isinstance(analysis_1m, dict) and 'reason' in analysis_1m): + indicators_1m_reject = { + 'rsi': analysis_1m.get('rsi'), + 'rsi_prev': analysis_1m.get('rsi_prev'), + 'macd': analysis_1m.get('macd'), + 'macd_signal': analysis_1m.get('macd_signal'), + 'macd_hist': analysis_1m.get('macd_hist'), + 'macd_hist_prev': analysis_1m.get('macd_hist_prev'), + 'adx': analysis_1m.get('adx'), + 'di_plus': analysis_1m.get('di_plus'), + 'di_minus': analysis_1m.get('di_minus'), + 'di_gap': analysis_1m.get('di_gap'), + 'ema9': analysis_1m.get('ema9'), + 'ema21': analysis_1m.get('ema21'), + 'ema_diff_pct': analysis_1m.get('ema_diff_pct'), + 'atr': analysis_1m.get('atr'), + 'atr_pct': analysis_1m.get('atr_pct'), + 'bb_upper': analysis_1m.get('bb_upper'), + 'bb_middle': analysis_1m.get('bb_middle'), + 'bb_lower': analysis_1m.get('bb_lower'), + 'bb_width': analysis_1m.get('bb_width'), + 'bb_distance_to_lower': analysis_1m.get('bb_distance_to_lower'), + 'bb_distance_to_upper': analysis_1m.get('bb_distance_to_upper'), + 'volume': analysis_1m.get('volume'), + 'volume_avg': analysis_1m.get('volume_avg'), + 'volume_ratio': analysis_1m.get('volumeSpike'), + 'volume_spike': analysis_1m.get('volumeSpike'), + } + if analysis_5m and not (isinstance(analysis_5m, dict) and 'reason' in analysis_5m): + indicators_5m_reject = { + 'rsi': analysis_5m.get('rsi'), + 'rsi_prev': analysis_5m.get('rsi_prev'), + 'macd': analysis_5m.get('macd'), + 'macd_signal': analysis_5m.get('macd_signal'), + 'macd_hist': analysis_5m.get('macd_hist'), + 'macd_hist_prev': analysis_5m.get('macd_hist_prev'), + 'adx': analysis_5m.get('adx'), + 'di_plus': analysis_5m.get('di_plus'), + 'di_minus': analysis_5m.get('di_minus'), + 'di_gap': analysis_5m.get('di_gap'), + 'ema9': analysis_5m.get('ema9'), + 'ema21': analysis_5m.get('ema21'), + 'ema_diff_pct': analysis_5m.get('ema_diff_pct'), + 'atr': analysis_5m.get('atr'), + 'atr_pct': analysis_5m.get('atr_pct'), + 'bb_upper': analysis_5m.get('bb_upper'), + 'bb_middle': analysis_5m.get('bb_middle'), + 'bb_lower': analysis_5m.get('bb_lower'), + 'bb_width': analysis_5m.get('bb_width'), + 'bb_distance_to_lower': analysis_5m.get('bb_distance_to_lower'), + 'bb_distance_to_upper': analysis_5m.get('bb_distance_to_upper'), + 'volume': analysis_5m.get('volume'), + 'volume_avg': analysis_5m.get('volume_avg'), + 'volume_ratio': analysis_5m.get('volumeSpike'), + 'volume_spike': analysis_5m.get('volumeSpike'), + } + return { + 'reason': f"Spread trop élevé ({spread_check['spread_pct']:.3f}% > {spread_check['max_allowed']:.3f}%)", + 'reject_category': 'spread', + 'analysis_1m': analysis_1m, + 'analysis_5m': analysis_5m, + 'indicators_1m': indicators_1m_reject, + 'indicators_5m': indicators_5m_reject, + 'totalScore': best_setup.get('totalScore'), + 'long_score': best_setup.get('long_score'), + 'short_score': best_setup.get('short_score'), + 'price': best_setup.get('price'), + 'symbol': symbol + } best_setup['spread_pct'] = spread_check['spread_pct'] best_setup['spread_quality'] = spread_check['quality'] @@ -975,7 +1059,80 @@ async def send_log(): pass except Exception as log_err: logger.debug(f"Impossible d'envoyer log au frontend: {log_err}") - return None + # 🔥 FIX: Retourner un dict avec les indicateurs et un reason au lieu de None + # pour permettre le logging des indicateurs même si le setup est rejeté + # Construire indicators_1m et indicators_5m depuis analysis_1m et analysis_5m + indicators_1m_reject = {} + indicators_5m_reject = {} + if analysis_1m and not (isinstance(analysis_1m, dict) and 'reason' in analysis_1m): + indicators_1m_reject = { + 'rsi': analysis_1m.get('rsi'), + 'rsi_prev': analysis_1m.get('rsi_prev'), + 'macd': analysis_1m.get('macd'), + 'macd_signal': analysis_1m.get('macd_signal'), + 'macd_hist': analysis_1m.get('macd_hist'), + 'macd_hist_prev': analysis_1m.get('macd_hist_prev'), + 'adx': analysis_1m.get('adx'), + 'di_plus': analysis_1m.get('di_plus'), + 'di_minus': analysis_1m.get('di_minus'), + 'di_gap': analysis_1m.get('di_gap'), + 'ema9': analysis_1m.get('ema9'), + 'ema21': analysis_1m.get('ema21'), + 'ema_diff_pct': analysis_1m.get('ema_diff_pct'), + 'atr': analysis_1m.get('atr'), + 'atr_pct': analysis_1m.get('atr_pct'), + 'bb_upper': analysis_1m.get('bb_upper'), + 'bb_middle': analysis_1m.get('bb_middle'), + 'bb_lower': analysis_1m.get('bb_lower'), + 'bb_width': analysis_1m.get('bb_width'), + 'bb_distance_to_lower': analysis_1m.get('bb_distance_to_lower'), + 'bb_distance_to_upper': analysis_1m.get('bb_distance_to_upper'), + 'volume': analysis_1m.get('volume'), + 'volume_avg': analysis_1m.get('volume_avg'), + 'volume_ratio': analysis_1m.get('volumeSpike'), + 'volume_spike': analysis_1m.get('volumeSpike'), + } + if analysis_5m and not (isinstance(analysis_5m, dict) and 'reason' in analysis_5m): + indicators_5m_reject = { + 'rsi': analysis_5m.get('rsi'), + 'rsi_prev': analysis_5m.get('rsi_prev'), + 'macd': analysis_5m.get('macd'), + 'macd_signal': analysis_5m.get('macd_signal'), + 'macd_hist': analysis_5m.get('macd_hist'), + 'macd_hist_prev': analysis_5m.get('macd_hist_prev'), + 'adx': analysis_5m.get('adx'), + 'di_plus': analysis_5m.get('di_plus'), + 'di_minus': analysis_5m.get('di_minus'), + 'di_gap': analysis_5m.get('di_gap'), + 'ema9': analysis_5m.get('ema9'), + 'ema21': analysis_5m.get('ema21'), + 'ema_diff_pct': analysis_5m.get('ema_diff_pct'), + 'atr': analysis_5m.get('atr'), + 'atr_pct': analysis_5m.get('atr_pct'), + 'bb_upper': analysis_5m.get('bb_upper'), + 'bb_middle': analysis_5m.get('bb_middle'), + 'bb_lower': analysis_5m.get('bb_lower'), + 'bb_width': analysis_5m.get('bb_width'), + 'bb_distance_to_lower': analysis_5m.get('bb_distance_to_lower'), + 'bb_distance_to_upper': analysis_5m.get('bb_distance_to_upper'), + 'volume': analysis_5m.get('volume'), + 'volume_avg': analysis_5m.get('volume_avg'), + 'volume_ratio': analysis_5m.get('volumeSpike'), + 'volume_spike': analysis_5m.get('volumeSpike'), + } + return { + 'reason': f"Orderbook défavorable (ratio={orderbook_check['ratio']:.2f}, required={required_str})", + 'reject_category': 'orderbook', + 'analysis_1m': analysis_1m, + 'analysis_5m': analysis_5m, + 'indicators_1m': indicators_1m_reject, + 'indicators_5m': indicators_5m_reject, + 'totalScore': best_setup.get('totalScore'), + 'long_score': best_setup.get('long_score'), + 'short_score': best_setup.get('short_score'), + 'price': best_setup.get('price'), + 'symbol': symbol + } # Bonus si orderbook très favorable if orderbook_check['quality'] == 'EXCELLENT': @@ -1116,9 +1273,9 @@ async def send_log(): best_setup['min_score_required'] = min_score_required # 🔥 FIX: Ajouter indicators_1m et indicators_5m à best_setup pour qu'ils soient disponibles dans _last_setup - # Construire indicators_1m depuis analysis_1m + # Construire indicators_1m depuis analysis_1m (MÊME SI REJETÉ - build_indicators_dict() inclut les indicateurs) indicators_1m = {} - if analysis_1m and not (isinstance(analysis_1m, dict) and 'reason' in analysis_1m): + if analysis_1m and isinstance(analysis_1m, dict): indicators_1m = { 'rsi': analysis_1m.get('rsi'), 'rsi_prev': analysis_1m.get('rsi_prev'), @@ -1154,9 +1311,9 @@ async def send_log(): 'volume_spike': analysis_1m.get('volumeSpike'), } - # Construire indicators_5m depuis analysis_5m + # Construire indicators_5m depuis analysis_5m (MÊME SI REJETÉ - build_indicators_dict() inclut les indicateurs) indicators_5m = {} - if analysis_5m and not (isinstance(analysis_5m, dict) and 'reason' in analysis_5m): + if analysis_5m and isinstance(analysis_5m, dict): indicators_5m = { 'rsi': analysis_5m.get('rsi'), 'rsi_prev': analysis_5m.get('rsi_prev'), @@ -1283,8 +1440,9 @@ async def send_log(): best['atr5m'] = analysis_5m['atr'] # 🔥 FIX: Ajouter indicators_1m et indicators_5m à best pour qu'ils soient disponibles dans _last_setup + # Construire indicators_1m depuis analysis_1m (MÊME SI REJETÉ - build_indicators_dict() inclut les indicateurs) indicators_1m = {} - if analysis_1m and not (isinstance(analysis_1m, dict) and 'reason' in analysis_1m): + if analysis_1m and isinstance(analysis_1m, dict): indicators_1m = { 'rsi': analysis_1m.get('rsi'), 'rsi_prev': analysis_1m.get('rsi_prev'), 'macd': analysis_1m.get('macd'), 'macd_signal': analysis_1m.get('macd_signal'), @@ -1300,8 +1458,9 @@ async def send_log(): 'volume': analysis_1m.get('volume'), 'volume_avg': analysis_1m.get('volume_avg'), 'volume_ratio': analysis_1m.get('volumeSpike'), 'volume_spike': analysis_1m.get('volumeSpike'), } + # Construire indicators_5m depuis analysis_5m (MÊME SI REJETÉ - build_indicators_dict() inclut les indicateurs) indicators_5m = {} - if analysis_5m and not (isinstance(analysis_5m, dict) and 'reason' in analysis_5m): + if analysis_5m and isinstance(analysis_5m, dict): indicators_5m = { 'rsi': analysis_5m.get('rsi'), 'rsi_prev': analysis_5m.get('rsi_prev'), 'macd': analysis_5m.get('macd'), 'macd_signal': analysis_5m.get('macd_signal'), @@ -1344,8 +1503,9 @@ async def send_log(): best['atr5m'] = analysis_5m['atr'] # 🔥 FIX: Ajouter indicators_1m et indicators_5m à best pour qu'ils soient disponibles dans _last_setup + # Construire indicators_1m depuis analysis_1m (MÊME SI REJETÉ - build_indicators_dict() inclut les indicateurs) indicators_1m = {} - if valid_1m: + if analysis_1m and isinstance(analysis_1m, dict): indicators_1m = { 'rsi': analysis_1m.get('rsi'), 'rsi_prev': analysis_1m.get('rsi_prev'), 'macd': analysis_1m.get('macd'), 'macd_signal': analysis_1m.get('macd_signal'), @@ -1361,8 +1521,9 @@ async def send_log(): 'volume': analysis_1m.get('volume'), 'volume_avg': analysis_1m.get('volume_avg'), 'volume_ratio': analysis_1m.get('volumeSpike'), 'volume_spike': analysis_1m.get('volumeSpike'), } + # Construire indicators_5m depuis analysis_5m (MÊME SI REJETÉ - build_indicators_dict() inclut les indicateurs) indicators_5m = {} - if valid_5m: + if analysis_5m and isinstance(analysis_5m, dict): indicators_5m = { 'rsi': analysis_5m.get('rsi'), 'rsi_prev': analysis_5m.get('rsi_prev'), 'macd': analysis_5m.get('macd'), 'macd_signal': analysis_5m.get('macd_signal'), @@ -1402,11 +1563,55 @@ async def send_log(): elif not analysis_5m: reasons.append("5m: None (pas de setup)") - if return_reason: - reason = f"Aucun timeframe valide. " + " | ".join(reasons) if reasons else "Aucune raison spécifique" - return {'reason': reason, 'symbol': symbol, 'timeframe': '1m+5m'} - - return None + # 🔥 FIX: Toujours retourner un dict avec analysis_1m et analysis_5m pour que scanner_loop.py puisse construire les indicateurs + reason = f"Aucun timeframe valide. " + " | ".join(reasons) if reasons else "Aucune raison spécifique" + result = { + 'reason': reason, + 'symbol': symbol, + 'timeframe': '1m+5m', + 'analysis_1m': analysis_1m if analysis_1m and isinstance(analysis_1m, dict) else None, + 'analysis_5m': analysis_5m if analysis_5m and isinstance(analysis_5m, dict) else None + } + + # Construire indicators_1m et indicators_5m même si aucun setup n'est valide + indicators_1m = {} + if analysis_1m and isinstance(analysis_1m, dict): + indicators_1m = { + 'rsi': analysis_1m.get('rsi'), 'rsi_prev': analysis_1m.get('rsi_prev'), + 'macd': analysis_1m.get('macd'), 'macd_signal': analysis_1m.get('macd_signal'), + 'macd_hist': analysis_1m.get('macd_hist'), 'macd_hist_prev': analysis_1m.get('macd_hist_prev'), + 'adx': analysis_1m.get('adx'), 'di_plus': analysis_1m.get('di_plus'), + 'di_minus': analysis_1m.get('di_minus'), + 'di_gap': (analysis_1m.get('di_plus', 0) - analysis_1m.get('di_minus', 0) if analysis_1m.get('di_plus') and analysis_1m.get('di_minus') else None), + 'ema9': analysis_1m.get('ema9'), 'ema21': analysis_1m.get('ema21'), + 'ema_diff_pct': (((analysis_1m.get('ema9', 0) - analysis_1m.get('ema21', 0)) / analysis_1m.get('ema21', 1)) * 100 if analysis_1m.get('ema21') else None), + 'atr': analysis_1m.get('atr'), 'atr_pct': analysis_1m.get('atr_pct'), + 'bb_upper': analysis_1m.get('bb_upper'), 'bb_middle': analysis_1m.get('bb_middle'), 'bb_lower': analysis_1m.get('bb_lower'), + 'bb_width': analysis_1m.get('bb_width'), 'bb_distance_to_lower': analysis_1m.get('bb_distance_to_lower'), 'bb_distance_to_upper': analysis_1m.get('bb_distance_to_upper'), + 'volume': analysis_1m.get('volume'), 'volume_avg': analysis_1m.get('volume_avg'), + 'volume_ratio': analysis_1m.get('volumeSpike'), 'volume_spike': analysis_1m.get('volumeSpike'), + } + indicators_5m = {} + if analysis_5m and isinstance(analysis_5m, dict): + indicators_5m = { + 'rsi': analysis_5m.get('rsi'), 'rsi_prev': analysis_5m.get('rsi_prev'), + 'macd': analysis_5m.get('macd'), 'macd_signal': analysis_5m.get('macd_signal'), + 'macd_hist': analysis_5m.get('macd_hist'), 'macd_hist_prev': analysis_5m.get('macd_hist_prev'), + 'adx': analysis_5m.get('adx'), 'di_plus': analysis_5m.get('di_plus'), + 'di_minus': analysis_5m.get('di_minus'), + 'di_gap': (analysis_5m.get('di_plus', 0) - analysis_5m.get('di_minus', 0) if analysis_5m.get('di_plus') and analysis_5m.get('di_minus') else None), + 'ema9': analysis_5m.get('ema9'), 'ema21': analysis_5m.get('ema21'), + 'ema_diff_pct': (((analysis_5m.get('ema9', 0) - analysis_5m.get('ema21', 0)) / analysis_5m.get('ema21', 1)) * 100 if analysis_5m.get('ema21') else None), + 'atr': analysis_5m.get('atr'), 'atr_pct': analysis_5m.get('atr_pct'), + 'bb_upper': analysis_5m.get('bb_upper'), 'bb_middle': analysis_5m.get('bb_middle'), 'bb_lower': analysis_5m.get('bb_lower'), + 'bb_width': analysis_5m.get('bb_width'), 'bb_distance_to_lower': analysis_5m.get('bb_distance_to_lower'), 'bb_distance_to_upper': analysis_5m.get('bb_distance_to_upper'), + 'volume': analysis_5m.get('volume'), 'volume_avg': analysis_5m.get('volume_avg'), + 'volume_ratio': analysis_5m.get('volumeSpike'), 'volume_spike': analysis_5m.get('volumeSpike'), + } + result['indicators_1m'] = indicators_1m + result['indicators_5m'] = indicators_5m + + return result except Exception as e: if DEBUG_ENABLED: diff --git a/core/callbacks/scanner_loop.py b/core/callbacks/scanner_loop.py index f3448c73..8f341857 100644 --- a/core/callbacks/scanner_loop.py +++ b/core/callbacks/scanner_loop.py @@ -6,6 +6,8 @@ import asyncio import logging from typing import Optional, Dict, Any +from core.postgresql_datalogger import PostgreSQLDataLogger +from core.simple_pg_logger import SimplePGLogger logger = logging.getLogger(__name__) @@ -18,7 +20,9 @@ _sio = None # 🔥 MIGRATION COMPLÈTE: Gardé pour compatibilité, mais utiliser _ws_manager _ws_manager = None # 🔥 MIGRATION COMPLÈTE: WebSocket natif _scanner_lock = None -_pg_datalogger = None # 🔥 PHASE 1: PostgreSQL DataLogger pour ML +_pg_datalogger = None # 🔥 PHASE 1: PostgreSQL DataLogger pour ML (injection) +_pg_datalogger_instance = None # 🔥 Force Initialization: Instance créée automatiquement +_simple_logger = SimplePGLogger() # 🔥 Simple Logger: Logger ultra-simple sans batch def set_scanner(scanner): @@ -75,8 +79,23 @@ def set_pg_datalogger(pg_datalogger): def get_pg_datalogger(): - """🔥 PHASE 2: Récupérer l'instance PostgreSQLDataLogger""" - return _pg_datalogger + """🔥 Force Initialization: Récupérer ou créer l'instance PostgreSQLDataLogger""" + global _pg_datalogger_instance + + # Si une instance a été injectée, l'utiliser en priorité + if _pg_datalogger is not None: + return _pg_datalogger + + # Sinon, créer une instance si elle n'existe pas + if _pg_datalogger_instance is None: + try: + _pg_datalogger_instance = PostgreSQLDataLogger() + logger.info("✅ PostgreSQL DataLogger créé (Force Initialization)") + except Exception as e: + logger.error(f"❌ Erreur création PostgreSQL DataLogger: {e}") + return None + + return _pg_datalogger_instance async def scanner_loop_callback(): @@ -287,9 +306,18 @@ async def _scan_top_pairs(): scalability_data = { 'spread_pct': spread_value, 'depth': book_depth, + 'book_depth': book_depth, # Alias 'balance': balance_score, + 'balance_score': balance_score, # Alias 'bid_vol': bid_vol, - 'ask_vol': ask_vol + 'ask_vol': ask_vol, + # Paramètres du scan de scalabilité + 'recent_volume': pair.get('recentVolume'), + 'recentVolume': pair.get('recentVolume'), # Alias + 'vol5': pair.get('vol5'), + 'vol15': pair.get('vol15'), + 'scalability_score': pair.get('score'), + 'score': pair.get('score'), # Alias } logger.info(f"💹 Données scalabilité récupérées depuis top_pairs: spread={spread_value}%, depth={book_depth}, balance={balance_score}") @@ -325,9 +353,18 @@ async def _scan_top_pairs(): scalability_data = { 'spread_pct': best_setup.get('spread_pct', 0), 'depth': orderbook_depth or best_setup.get('orderbook_depth', 0) or (best_setup.get('bid_vol', 0) + best_setup.get('ask_vol', 0)), + 'book_depth': orderbook_depth or best_setup.get('orderbook_depth', 0) or (best_setup.get('bid_vol', 0) + best_setup.get('ask_vol', 0)), # Alias 'balance': best_setup.get('orderbook_balance', 1.0) or best_setup.get('orderbook_check', {}).get('balance', 1.0), + 'balance_score': best_setup.get('orderbook_balance', 1.0) or best_setup.get('orderbook_check', {}).get('balance', 1.0), # Alias 'bid_vol': best_setup.get('bid_vol'), - 'ask_vol': best_setup.get('ask_vol') + 'ask_vol': best_setup.get('ask_vol'), + # Paramètres du scan de scalabilité (essayer depuis best_setup ou top_pairs) + 'recent_volume': best_setup.get('recent_volume') or best_setup.get('recentVolume'), + 'recentVolume': best_setup.get('recent_volume') or best_setup.get('recentVolume'), # Alias + 'vol5': best_setup.get('vol5'), + 'vol15': best_setup.get('vol15'), + 'scalability_score': best_setup.get('scalability_score') or best_setup.get('score'), + 'score': best_setup.get('scalability_score') or best_setup.get('score'), # Alias } logger.info(f"💹 Données scalabilité depuis best_setup: spread={scalability_data.get('spread_pct')}%, depth={scalability_data.get('depth')}") else: @@ -470,93 +507,255 @@ async def scan_pair_for_setup(symbol: str) -> Optional[Dict[str, Any]]: # Si les indicateurs ne sont pas présents, essayer de les construire depuis les données disponibles if not indicators_1m: logger.info(f"🔧 Construction indicators_1m depuis analysis pour {symbol}") - # Construire indicators_1m depuis les données disponibles dans analysis - indicators_1m = { - 'rsi': analysis.get('rsi'), - 'rsi_prev': analysis.get('rsi_prev'), - 'macd': analysis.get('macd'), - 'macd_signal': analysis.get('macd_signal'), - 'macd_hist': analysis.get('macd_hist'), - 'macd_hist_prev': analysis.get('macd_hist_prev'), - 'adx': analysis.get('adx'), - 'di_plus': analysis.get('di_plus'), - 'di_minus': analysis.get('di_minus'), - 'di_gap': analysis.get('di_gap'), - 'ema9': analysis.get('ema9'), - 'ema21': analysis.get('ema21'), - 'ema_diff_pct': analysis.get('ema_diff_pct'), - 'atr': analysis.get('atr'), - 'atr_pct': analysis.get('atr_pct'), - 'bb_upper': analysis.get('bb_upper'), - 'bb_middle': analysis.get('bb_middle'), - 'bb_lower': analysis.get('bb_lower'), - 'bb_width': analysis.get('bb_width'), - 'bb_distance_to_lower': analysis.get('bb_distance_to_lower'), - 'bb_distance_to_upper': analysis.get('bb_distance_to_upper'), - 'volume': analysis.get('volume'), - 'volume_avg': analysis.get('volume_avg'), - 'volume_ratio': analysis.get('volume_ratio') or analysis.get('volumeSpike'), - 'volume_spike': analysis.get('volume_spike'), - } + # Si analysis contient 'reason' (aucun setup valide), extraire depuis analysis_1m + if 'reason' in analysis and 'analysis_1m' in analysis and analysis['analysis_1m']: + analysis_1m = analysis['analysis_1m'] + indicators_1m = { + 'rsi': analysis_1m.get('rsi'), + 'rsi_prev': analysis_1m.get('rsi_prev'), + 'macd': analysis_1m.get('macd'), + 'macd_signal': analysis_1m.get('macd_signal'), + 'macd_hist': analysis_1m.get('macd_hist'), + 'macd_hist_prev': analysis_1m.get('macd_hist_prev'), + 'adx': analysis_1m.get('adx'), + 'di_plus': analysis_1m.get('di_plus'), + 'di_minus': analysis_1m.get('di_minus'), + 'di_gap': analysis_1m.get('di_gap'), + 'ema9': analysis_1m.get('ema9'), + 'ema21': analysis_1m.get('ema21'), + 'ema_diff_pct': analysis_1m.get('ema_diff_pct'), + 'atr': analysis_1m.get('atr'), + 'atr_pct': analysis_1m.get('atr_pct'), + 'bb_upper': analysis_1m.get('bb_upper'), + 'bb_middle': analysis_1m.get('bb_middle'), + 'bb_lower': analysis_1m.get('bb_lower'), + 'bb_width': analysis_1m.get('bb_width'), + 'bb_distance_to_lower': analysis_1m.get('bb_distance_to_lower'), + 'bb_distance_to_upper': analysis_1m.get('bb_distance_to_upper'), + 'volume': analysis_1m.get('volume'), + 'volume_avg': analysis_1m.get('volume_avg'), + 'volume_ratio': analysis_1m.get('volumeSpike'), + 'volume_spike': analysis_1m.get('volumeSpike'), + } + else: + # Construire indicators_1m depuis les données disponibles dans analysis + indicators_1m = { + 'rsi': analysis.get('rsi'), + 'rsi_prev': analysis.get('rsi_prev'), + 'macd': analysis.get('macd'), + 'macd_signal': analysis.get('macd_signal'), + 'macd_hist': analysis.get('macd_hist'), + 'macd_hist_prev': analysis.get('macd_hist_prev'), + 'adx': analysis.get('adx'), + 'di_plus': analysis.get('di_plus'), + 'di_minus': analysis.get('di_minus'), + 'di_gap': analysis.get('di_gap'), + 'ema9': analysis.get('ema9'), + 'ema21': analysis.get('ema21'), + 'ema_diff_pct': analysis.get('ema_diff_pct'), + 'atr': analysis.get('atr'), + 'atr_pct': analysis.get('atr_pct'), + 'bb_upper': analysis.get('bb_upper'), + 'bb_middle': analysis.get('bb_middle'), + 'bb_lower': analysis.get('bb_lower'), + 'bb_width': analysis.get('bb_width'), + 'bb_distance_to_lower': analysis.get('bb_distance_to_lower'), + 'bb_distance_to_upper': analysis.get('bb_distance_to_upper'), + 'volume': analysis.get('volume'), + 'volume_avg': analysis.get('volume_avg'), + 'volume_ratio': analysis.get('volume_ratio') or analysis.get('volumeSpike'), + 'volume_spike': analysis.get('volume_spike'), + } # Si indicators_5m n'est pas présent, essayer de le construire depuis les données disponibles if not indicators_5m: logger.info(f"🔧 Construction indicators_5m depuis analysis pour {symbol}") - # Pour indicators_5m, on peut utiliser les mêmes données ou des variantes 5m si disponibles - indicators_5m = { - 'rsi': analysis.get('rsi_5m'), - 'rsi_prev': analysis.get('rsi_prev_5m'), - 'macd': analysis.get('macd_5m'), - 'macd_signal': analysis.get('macd_signal_5m'), - 'macd_hist': analysis.get('macd_hist_5m'), - 'macd_hist_prev': analysis.get('macd_hist_prev_5m'), - 'adx': analysis.get('adx_5m'), - 'di_plus': analysis.get('di_plus_5m'), - 'di_minus': analysis.get('di_minus_5m'), - 'di_gap': analysis.get('di_gap_5m'), - 'ema9': analysis.get('ema9_5m'), - 'ema21': analysis.get('ema21_5m'), - 'ema_diff_pct': analysis.get('ema_diff_pct_5m'), - 'atr': analysis.get('atr5m') or analysis.get('atr_5m'), - 'atr_pct': analysis.get('atr_pct_5m'), - 'bb_upper': analysis.get('bb_upper_5m'), - 'bb_middle': analysis.get('bb_middle_5m'), - 'bb_lower': analysis.get('bb_lower_5m'), - 'bb_width': analysis.get('bb_width_5m'), - 'bb_distance_to_lower': analysis.get('bb_distance_to_lower_5m'), - 'bb_distance_to_upper': analysis.get('bb_distance_to_upper_5m'), - 'volume': analysis.get('volume_5m'), - 'volume_avg': analysis.get('volume_avg_5m'), - 'volume_ratio': analysis.get('volume_ratio_5m'), - 'volume_spike': analysis.get('volume_spike_5m'), - } + # Si analysis contient 'reason' (aucun setup valide), extraire depuis analysis_5m + if 'reason' in analysis and 'analysis_5m' in analysis and analysis['analysis_5m']: + analysis_5m = analysis['analysis_5m'] + indicators_5m = { + 'rsi': analysis_5m.get('rsi'), + 'rsi_prev': analysis_5m.get('rsi_prev'), + 'macd': analysis_5m.get('macd'), + 'macd_signal': analysis_5m.get('macd_signal'), + 'macd_hist': analysis_5m.get('macd_hist'), + 'macd_hist_prev': analysis_5m.get('macd_hist_prev'), + 'adx': analysis_5m.get('adx'), + 'di_plus': analysis_5m.get('di_plus'), + 'di_minus': analysis_5m.get('di_minus'), + 'di_gap': analysis_5m.get('di_gap'), + 'ema9': analysis_5m.get('ema9'), + 'ema21': analysis_5m.get('ema21'), + 'ema_diff_pct': analysis_5m.get('ema_diff_pct'), + 'atr': analysis_5m.get('atr'), + 'atr_pct': analysis_5m.get('atr_pct'), + 'bb_upper': analysis_5m.get('bb_upper'), + 'bb_middle': analysis_5m.get('bb_middle'), + 'bb_lower': analysis_5m.get('bb_lower'), + 'bb_width': analysis_5m.get('bb_width'), + 'bb_distance_to_lower': analysis_5m.get('bb_distance_to_lower'), + 'bb_distance_to_upper': analysis_5m.get('bb_distance_to_upper'), + 'volume': analysis_5m.get('volume'), + 'volume_avg': analysis_5m.get('volume_avg'), + 'volume_ratio': analysis_5m.get('volumeSpike'), + 'volume_spike': analysis_5m.get('volumeSpike'), + } + else: + # Pour indicators_5m, on peut utiliser les mêmes données ou des variantes 5m si disponibles + indicators_5m = { + 'rsi': analysis.get('rsi_5m'), + 'rsi_prev': analysis.get('rsi_prev_5m'), + 'macd': analysis.get('macd_5m'), + 'macd_signal': analysis.get('macd_signal_5m'), + 'macd_hist': analysis.get('macd_hist_5m'), + 'macd_hist_prev': analysis.get('macd_hist_prev_5m'), + 'adx': analysis.get('adx_5m'), + 'di_plus': analysis.get('di_plus_5m'), + 'di_minus': analysis.get('di_minus_5m'), + 'di_gap': analysis.get('di_gap_5m'), + 'ema9': analysis.get('ema9_5m'), + 'ema21': analysis.get('ema21_5m'), + 'ema_diff_pct': analysis.get('ema_diff_pct_5m'), + 'atr': analysis.get('atr5m') or analysis.get('atr_5m'), + 'atr_pct': analysis.get('atr_pct_5m'), + 'bb_upper': analysis.get('bb_upper_5m'), + 'bb_middle': analysis.get('bb_middle_5m'), + 'bb_lower': analysis.get('bb_lower_5m'), + 'bb_width': analysis.get('bb_width_5m'), + 'bb_distance_to_lower': analysis.get('bb_distance_to_lower_5m'), + 'bb_distance_to_upper': analysis.get('bb_distance_to_upper_5m'), + 'volume': analysis.get('volume_5m'), + 'volume_avg': analysis.get('volume_avg_5m'), + 'volume_ratio': analysis.get('volume_ratio_5m'), + 'volume_spike': analysis.get('volume_spike_5m'), + } # Ajouter les indicateurs à analysis analysis['indicators_1m'] = indicators_1m analysis['indicators_5m'] = indicators_5m logger.info(f"✅ Indicateurs ajoutés à analysis pour {symbol}: indicators_1m keys: {len(indicators_1m)}, indicators_5m keys: {len(indicators_5m)}") - logger.info(f"🔍 DEBUG analysis après ajout indicateurs: keys: {list(analysis.keys())[:20]}") else: logger.warning(f"⚠️ analysis n'est pas un dict pour {symbol}: {type(analysis)}") + # 🔥 DEBUG: Vérifier que le code atteint cette section AVANT Simple Logger + logger.info(f"🔍 DEBUG scan_pair_for_setup({symbol}): AVANT Simple Logger, analysis type: {type(analysis)}") + + # 🔥 Simple Logger: Logger ultra-simple sans batch + try: + # Vérifier que _simple_logger est défini et accessible + try: + logger.info(f"🔍 DEBUG Simple Logger pour {symbol}: _simple_logger existe, enabled={getattr(_simple_logger, 'enabled', 'ATTRIBUT_MANQUANT')}") + except NameError: + logger.error(f"❌ _simple_logger n'est pas défini pour {symbol}") + _simple_logger = None + except Exception as e: + logger.error(f"❌ Erreur accès _simple_logger pour {symbol}: {e}") + _simple_logger = None + + if _simple_logger and hasattr(_simple_logger, 'enabled') and _simple_logger.enabled: + logger.info(f"📝 Tentative log_scan_simple pour {symbol}") + result = _simple_logger.log_scan_simple(symbol, { + 'market_data': {'price': analysis.get('price') if analysis else None}, + 'indicators_1m': analysis.get('indicators_1m', {}) if analysis else {}, + 'scores': {'score_total': analysis.get('score_total') if analysis else None}, + 'is_opportunity': bool(analysis and 'direction' in analysis and ('entry' in analysis or 'price' in analysis)) if analysis else False + }) + logger.info(f"📝 Résultat log_scan_simple pour {symbol}: {result}") + else: + logger.warning(f"⚠️ Simple Logger désactivé pour {symbol}") + except Exception as e: + logger.error(f"❌ Erreur Simple Logger pour {symbol}: {e}") + import traceback + logger.debug(f"Traceback: {traceback.format_exc()}") + + # 🔥 DEBUG: Vérifier que le code atteint cette section + logger.info(f"🔍 DEBUG scan_pair_for_setup({symbol}): APRÈS ajout indicateurs, AVANT calcul durée scan") + # 🔥 PHASE 3: Calculer durée du scan - scan_duration_ms = int((time.time() - scan_start_time) * 1000) + try: + scan_duration_ms = int((time.time() - scan_start_time) * 1000) + logger.info(f"🔍 DEBUG scan_pair_for_setup({symbol}): scan_duration_ms={scan_duration_ms}ms, AVANT vérification pg_datalogger") + except Exception as e: + logger.error(f"❌ Erreur calcul scan_duration_ms pour {symbol}: {e}") + scan_duration_ms = 0 # 🔥 PHASE 1: Logger le scan dans PostgreSQL si activé - if _pg_datalogger and _pg_datalogger.enabled: + # Force Initialization: Utiliser get_pg_datalogger() qui crée l'instance si nécessaire + pg_datalogger = get_pg_datalogger() + + try: + logger.info(f"🔍 DEBUG scan_pair_for_setup({symbol}): pg_datalogger={pg_datalogger is not None}, enabled={getattr(pg_datalogger, 'enabled', False) if pg_datalogger else False}") + except Exception as e: + logger.error(f"❌ Erreur log pg_datalogger pour {symbol}: {e}") + + if pg_datalogger and pg_datalogger.enabled: try: + logger.info(f"📝 Tentative de log scan PostgreSQL pour {symbol}") + # Récupérer les données du scan de scalabilité depuis top_pairs + scalability_data = {} + if _app_state and _app_state.get('top_pairs'): + for pair in _app_state['top_pairs']: + if pair.get('symbol') == symbol: + scalability_data = { + 'recent_volume': pair.get('recentVolume'), + 'vol5': pair.get('vol5'), + 'vol15': pair.get('vol15'), + 'scalability_score': pair.get('score'), + } + break + # Préparer les données du scan pour PostgreSQL + # 🔥 FIX: Récupérer le prix avec fallbacks (pour éviter NULL) + scan_price = None + if analysis and isinstance(analysis, dict): + scan_price = analysis.get('price') + + # Fallback: Depuis analysis_1m ou analysis_5m si disponible + if scan_price is None and analysis: + analysis_1m = analysis.get('analysis_1m', {}) + if isinstance(analysis_1m, dict): + scan_price = analysis_1m.get('price') + if scan_price is None: + analysis_5m = analysis.get('analysis_5m', {}) + if isinstance(analysis_5m, dict): + scan_price = analysis_5m.get('price') + + # Extraire la valeur numérique si scan_price est un dict + if isinstance(scan_price, dict): + scan_price = scan_price.get('price') or scan_price.get('lastPrice') or scan_price.get('close') or scan_price.get('value') + + # Vérifier que scan_price est un nombre + if scan_price is not None and not isinstance(scan_price, (int, float)): + try: + scan_price = float(scan_price) + except (ValueError, TypeError): + logger.warning(f"⚠️ Prix invalide pour {symbol}: {scan_price} (type: {type(scan_price)})") + scan_price = None + scan_data = { 'scan_duration_ms': scan_duration_ms, 'market_data': { - 'price': analysis.get('price') if analysis else None, + 'price': scan_price, 'spread_pct': analysis.get('spread_pct') if analysis else None, 'book_depth': analysis.get('book_depth') if analysis else None, 'balance_score': analysis.get('balance_score') if analysis else None, 'bid_vol': analysis.get('bid_vol') if analysis else None, 'ask_vol': analysis.get('ask_vol') if analysis else None, 'orderbook_imbalance_ratio': analysis.get('orderbook_imbalance_ratio') if analysis else None, + # Paramètres du scan de scalabilité + 'recent_volume': scalability_data.get('recent_volume'), + 'vol5': scalability_data.get('vol5'), + 'vol15': scalability_data.get('vol15'), + 'scalability_score': scalability_data.get('scalability_score'), }, + # Ajouter aussi au niveau racine pour les fallbacks + 'price': scan_price, # 🔥 FIX: Ajouter le prix au niveau racine pour les fallbacks + 'recent_volume': scalability_data.get('recent_volume'), + 'recentVolume': scalability_data.get('recent_volume'), # Alias + 'vol5': scalability_data.get('vol5'), + 'vol15': scalability_data.get('vol15'), + 'scalability_score': scalability_data.get('scalability_score'), + 'score': scalability_data.get('scalability_score'), # Alias 'indicators_1m': analysis.get('indicators_1m', {}) if analysis else {}, 'indicators_5m': analysis.get('indicators_5m', {}) if analysis else {}, 'filters': analysis.get('filters', {}) if analysis else {}, @@ -579,8 +778,8 @@ async def scan_pair_for_setup(symbol: str) -> Optional[Dict[str, Any]]: 'confluence_met': analysis.get('confluence_met') if analysis else False, 'timeframes_aligned': analysis.get('timeframes_aligned') if analysis else False, 'trend_timeframe': trend_timeframe, - 'trend_direction': trend_data.get('direction') if trend_data else None, - 'trend_strength': trend_data.get('strength') if trend_data else None, + 'trend_direction': trend_data.get('trend') if trend_data else None, # 'trend' pas 'direction' + 'trend_strength': None, # trend_data.get('strength') est une chaîne ('STRONG', 'MODERATE', 'NONE'), pas un FLOAT 'trend_bonus': trend_data.get('bonus') if trend_data else None, 'divergence_detected': analysis.get('divergence_detected') if analysis else False, 'divergence_type': analysis.get('divergence_type') if analysis else None, @@ -612,7 +811,9 @@ async def scan_pair_for_setup(symbol: str) -> Optional[Dict[str, Any]]: } # Logger le scan (mode batch par défaut) - scan_id = _pg_datalogger.log_scan(symbol, scan_data, use_batch=True) + logger.info(f"📝 Appel log_scan() pour {symbol}") + scan_id = pg_datalogger.log_scan(symbol, scan_data, use_batch=True) + logger.info(f"✅ log_scan() terminé pour {symbol} (scan_id={scan_id})") # Si c'est une opportunité, logger aussi dans opportunities # Note: En mode batch, scan_id est None, mais l'opportunité sera loggée @@ -632,7 +833,7 @@ async def scan_pair_for_setup(symbol: str) -> Optional[Dict[str, Any]]: } # En mode batch, on passe scan_id=None temporairement # Le scan_id sera résolu lors du flush batch - _pg_datalogger.log_opportunity( + pg_datalogger.log_opportunity( scan_id or 0, # 0 = temporaire, sera mis à jour symbol, opportunity_data, @@ -640,11 +841,9 @@ async def scan_pair_for_setup(symbol: str) -> Optional[Dict[str, Any]]: ) except Exception as e: - logger.warning(f"⚠️ Erreur logging PostgreSQL pour {symbol}: {e}") - - # 🔥 DEBUG: Vérifier avant return - if analysis and isinstance(analysis, dict): - logger.info(f"🔍 DEBUG scan_pair_for_setup({symbol}) AVANT RETURN: contient indicators_1m: {'indicators_1m' in analysis}, indicators_5m: {'indicators_5m' in analysis}") + logger.error(f"❌ Erreur logging PostgreSQL pour {symbol}: {e}") + import traceback + logger.debug(f"Traceback: {traceback.format_exc()}") return analysis @@ -652,7 +851,10 @@ async def scan_pair_for_setup(symbol: str) -> Optional[Dict[str, Any]]: logger.error(f"❌ Erreur analyse {symbol}: {e}") # 🔥 PHASE 3: Logger l'erreur dans PostgreSQL si activé - if _pg_datalogger and _pg_datalogger.enabled: + # Force Initialization: Utiliser get_pg_datalogger() qui crée l'instance si nécessaire + pg_datalogger = get_pg_datalogger() + + if pg_datalogger and pg_datalogger.enabled: try: import traceback error_details = { @@ -660,7 +862,7 @@ async def scan_pair_for_setup(symbol: str) -> Optional[Dict[str, Any]]: 'error_message': str(e), 'stack': traceback.format_exc() } - _pg_datalogger.log_scan_error( + pg_datalogger.log_scan_error( symbol=symbol, error_type='SCAN_ERROR', error_message=str(e), diff --git a/core/position_manager.py b/core/position_manager.py index fa2cc2d7..a602d93f 100644 --- a/core/position_manager.py +++ b/core/position_manager.py @@ -10,7 +10,7 @@ import logging import time import uuid -from datetime import datetime +from datetime import datetime, timezone from typing import Optional, Dict, Any, List from dataclasses import dataclass, field @@ -491,13 +491,20 @@ def open_position( opportunity_id = getattr(self, '_last_setup_opportunity_id', None) last_setup = getattr(self, '_last_setup', None) - # 🔥 DEBUG: Log pour vérifier si _last_setup est disponible + # 🔥 FIX BUG #4: Monitoring amélioré - Log d'alerte si _last_setup est None if not last_setup: - logger.warning(f"⚠️ _last_setup est None pour {symbol} - les indicateurs d'entrée ne seront pas disponibles") + logger.error( + f"❌ BUG #4: _last_setup est None pour {symbol} - " + f"Les indicateurs d'entrée ne seront PAS disponibles dans PostgreSQL. " + f"Vérifier que scanner_loop.py stocke correctement _last_setup dans position_manager." + ) else: logger.info(f"✅ _last_setup disponible pour {symbol}, keys: {list(last_setup.keys())[:10]}") if 'indicators_1m' not in last_setup and 'indicators_5m' not in last_setup: - logger.warning(f"⚠️ _last_setup ne contient pas 'indicators_1m' ou 'indicators_5m' pour {symbol}") + logger.warning( + f"⚠️ BUG #4: _last_setup ne contient pas 'indicators_1m' ou 'indicators_5m' pour {symbol}. " + f"Vérifier que analyzer.py ajoute ces indicateurs au setup retourné." + ) # Préparer entry_indicators (snapshot au moment de l'entrée) # Récupérer depuis setup si disponible @@ -853,9 +860,10 @@ async def check_position(self, current_price: float) -> Optional[str]: invalidation_threshold = self.early_invalidation.get_adaptive_threshold( elapsed, atr_pct or 0.5 ) + # 🔥 FIX BUG #3: Utiliser timezone.utc pour PostgreSQL TIMESTAMPTZ early_invalidation_data = { 'triggered': True, - 'triggered_at': datetime.now().isoformat(), + 'triggered_at': datetime.now(timezone.utc).isoformat(), 'threshold': invalidation_threshold, 'elapsed': elapsed, 'atr_pct': atr_pct, @@ -1155,8 +1163,9 @@ def close_position(self, exit_price: float, reason: str) -> Dict[str, Any]: # Construire résultat # 🔥 FIX: Ajouter opened_at et closed_at pour l'affichage frontend + # 🔥 FIX BUG #3: Utiliser timezone.utc pour PostgreSQL TIMESTAMPTZ opened_at = datetime.fromtimestamp(self.active_position.start_time).isoformat() if hasattr(self.active_position, 'start_time') else self.active_position.timestamp - closed_at = datetime.now().isoformat() + closed_at = datetime.now(timezone.utc).isoformat() result = { 'symbol': self.active_position.symbol, @@ -1281,27 +1290,31 @@ def close_position(self, exit_price: float, reason: str) -> Dict[str, Any]: elif hasattr(self.active_position, 'timestamp'): timestamp_entry = self.active_position.timestamp else: - timestamp_entry = datetime.now().isoformat() + # 🔥 FIX BUG #3: Utiliser timezone.utc pour PostgreSQL TIMESTAMPTZ + timestamp_entry = datetime.now(timezone.utc).isoformat() - timestamp_exit = datetime.now().isoformat() + # 🔥 FIX BUG #3: Utiliser timezone.utc pour PostgreSQL TIMESTAMPTZ + timestamp_exit = datetime.now(timezone.utc).isoformat() # Préparer config_snapshot complet (toutes les variables de configuration) + # 🔥 FIX BUG #1: Utiliser serialize_config_safe() pour éviter les erreurs de sérialisation from config import ( TRADING_CONFIG, RISK_CONFIG, CONDITION_WEIGHTS, TREND_BONUS_CONFIG, RETRY_CONFIG, CIRCUIT_BREAKER_CONFIG, WEBSOCKET_CONFIG ) + from core.postgresql_datalogger import serialize_config_safe config_snapshot = {} - # Copier TRADING_CONFIG + # Copier TRADING_CONFIG avec sérialisation safe if TRADING_CONFIG: - config_snapshot.update(TRADING_CONFIG.copy()) - # Ajouter les variables définies séparément (elles ne sont pas dans TRADING_CONFIG) - config_snapshot['RISK_CONFIG'] = RISK_CONFIG - config_snapshot['CONDITION_WEIGHTS'] = CONDITION_WEIGHTS - config_snapshot['TREND_BONUS_CONFIG'] = TREND_BONUS_CONFIG - config_snapshot['RETRY_CONFIG'] = RETRY_CONFIG - config_snapshot['CIRCUIT_BREAKER_CONFIG'] = CIRCUIT_BREAKER_CONFIG - config_snapshot['WEBSOCKET_CONFIG'] = WEBSOCKET_CONFIG + config_snapshot.update(serialize_config_safe(TRADING_CONFIG)) + # Ajouter les variables définies séparément avec sérialisation safe + config_snapshot['RISK_CONFIG'] = serialize_config_safe(RISK_CONFIG) if RISK_CONFIG else {} + config_snapshot['CONDITION_WEIGHTS'] = serialize_config_safe(CONDITION_WEIGHTS) if CONDITION_WEIGHTS else {} + config_snapshot['TREND_BONUS_CONFIG'] = serialize_config_safe(TREND_BONUS_CONFIG) if TREND_BONUS_CONFIG else {} + config_snapshot['RETRY_CONFIG'] = serialize_config_safe(RETRY_CONFIG) if RETRY_CONFIG else {} + config_snapshot['CIRCUIT_BREAKER_CONFIG'] = serialize_config_safe(CIRCUIT_BREAKER_CONFIG) if CIRCUIT_BREAKER_CONFIG else {} + config_snapshot['WEBSOCKET_CONFIG'] = serialize_config_safe(WEBSOCKET_CONFIG) if WEBSOCKET_CONFIG else {} # Préparer indicateurs de sortie (vide pour l'instant, sera rempli plus tard si nécessaire) # TODO: Faire un scan rapide au moment de la fermeture pour récupérer les indicateurs de sortie diff --git a/core/postgresql_datalogger.py b/core/postgresql_datalogger.py index b0c79215..343ec928 100644 --- a/core/postgresql_datalogger.py +++ b/core/postgresql_datalogger.py @@ -7,7 +7,8 @@ import logging import os from typing import Dict, Any, Optional, List -from datetime import datetime +from datetime import datetime, timezone, date +from decimal import Decimal import json import uuid from collections import deque @@ -26,6 +27,111 @@ logger = logging.getLogger(__name__) +def _extract_numeric_value(value: Any) -> Optional[float]: + """ + 🔥 FIX: Extraire une valeur numérique depuis un dict ou autre type + + Args: + value: Valeur à extraire (peut être dict, float, int, str, None) + + Returns: + float ou None + """ + if value is None: + return None + + # Si c'est déjà un nombre + if isinstance(value, (int, float)): + return float(value) + + # Si c'est un dict, essayer d'extraire une valeur numérique + if isinstance(value, dict): + # Essayer plusieurs clés communes + for key in ['value', 'price', 'score', 'rsi', 'macd', 'adx', 'atr', 'volume', 'spread', 'balance', 'depth', 'vol5', 'vol15']: + if key in value: + nested_value = value[key] + if isinstance(nested_value, (int, float)): + return float(nested_value) + elif isinstance(nested_value, str): + try: + return float(nested_value) + except (ValueError, TypeError): + continue + # Si aucun champ numérique trouvé, retourner None + return None + + # Si c'est une string, essayer de convertir + if isinstance(value, str): + try: + return float(value) + except (ValueError, TypeError): + return None + + # Autre type non supporté + return None + + +def serialize_config_safe(config: Dict[str, Any]) -> Dict[str, Any]: + """ + 🔥 FIX BUG #1: Convertir config en JSON-safe dict + + Gère les types non-JSON sérialisables : + - Fonctions → string + - datetime/date → ISO format + - Decimal → float + - Objets custom → string ou dict si possible + + Args: + config: Dictionnaire de configuration + + Returns: + Dictionnaire JSON-safe + """ + if not config: + return {} + + serialized = {} + for key, value in config.items(): + try: + # Test rapide de sérialisabilité + json.dumps(value) + serialized[key] = value + except (TypeError, ValueError): + # Gestion par type non-sérialisable + if callable(value): + # Fonction → string avec nom + func_name = getattr(value, '__name__', 'unknown') + serialized[key] = f"" + logger.debug(f"🔧 Config key '{key}': fonction convertie en string: {func_name}") + elif isinstance(value, (datetime, date)): + # datetime/date → ISO format + serialized[key] = value.isoformat() + logger.debug(f"🔧 Config key '{key}': datetime converti en ISO: {value.isoformat()}") + elif isinstance(value, Decimal): + # Decimal → float + serialized[key] = float(value) + logger.debug(f"🔧 Config key '{key}': Decimal converti en float: {float(value)}") + elif hasattr(value, '__dict__'): + # Objet custom → essayer de sérialiser __dict__ récursivement + try: + serialized[key] = serialize_config_safe(value.__dict__) + logger.debug(f"🔧 Config key '{key}': objet {type(value).__name__} converti récursivement") + except Exception as e: + # Si échec, convertir en string + serialized[key] = f"<{type(value).__name__}>" + logger.warning(f"⚠️ Config key '{key}': objet {type(value).__name__} non sérialisable, converti en string: {e}") + elif isinstance(value, (set, frozenset)): + # Set → list + serialized[key] = list(value) + logger.debug(f"🔧 Config key '{key}': set converti en list") + else: + # Dernier recours : convertir en string + serialized[key] = str(value) + logger.warning(f"⚠️ Config key '{key}': type {type(value).__name__} non sérialisable, converti en string: {str(value)[:50]}") + + return serialized + + class PostgreSQLDataLogger: """ DataLogger pour PostgreSQL @@ -44,8 +150,8 @@ def __init__( password: Optional[str] = None, min_conn: int = 1, max_conn: int = 5, - batch_size: int = 50, - batch_flush_interval: float = 5.0 + batch_size: int = 10, + batch_flush_interval: float = 2.0 ): """ Initialiser PostgreSQL DataLogger @@ -59,8 +165,8 @@ def __init__( password: Mot de passe (si connection_string non fourni) min_conn: Nombre minimum de connexions dans le pool max_conn: Nombre maximum de connexions dans le pool - batch_size: Taille du buffer pour batch inserts (défaut: 50) - batch_flush_interval: Intervalle en secondes pour flush automatique (défaut: 5.0) + batch_size: Taille du buffer pour batch inserts (défaut: 10, réduit de 50 pour flush plus fréquent) + batch_flush_interval: Intervalle en secondes pour flush automatique (défaut: 2.0, réduit de 5.0 pour flush plus fréquent) """ if not PSYCOPG2_AVAILABLE: logger.error("❌ psycopg2 non disponible - PostgreSQL DataLogger désactivé") @@ -103,7 +209,8 @@ def __init__( self.scan_buffer: deque = deque(maxlen=batch_size * 2) # Buffer pour scans self.opportunity_buffer: deque = deque(maxlen=batch_size * 2) # Buffer pour opportunités self.buffer_lock = threading.Lock() - self.last_flush_time = datetime.now() + # 🔥 FIX BUG #3: Utiliser timezone.utc pour PostgreSQL TIMESTAMPTZ + self.last_flush_time = datetime.now(timezone.utc) def _get_connection(self): """Obtenir une connexion du pool""" @@ -194,6 +301,7 @@ def get_or_create_session(self, session_id: Optional[str] = None) -> Optional[st RETURNING id """ # Préparer config_snapshot complet (toutes les variables de configuration) + # 🔥 FIX BUG #1: Utiliser serialize_config_safe() pour éviter les erreurs de sérialisation try: from config import ( TRADING_CONFIG, RISK_CONFIG, CONDITION_WEIGHTS, @@ -201,19 +309,20 @@ def get_or_create_session(self, session_id: Optional[str] = None) -> Optional[st WEBSOCKET_CONFIG ) config_snapshot_dict = {} - # Copier TRADING_CONFIG + # Copier TRADING_CONFIG avec sérialisation safe if TRADING_CONFIG: - config_snapshot_dict.update(TRADING_CONFIG.copy()) - # Ajouter les variables définies séparément - config_snapshot_dict['RISK_CONFIG'] = RISK_CONFIG - config_snapshot_dict['CONDITION_WEIGHTS'] = CONDITION_WEIGHTS - config_snapshot_dict['TREND_BONUS_CONFIG'] = TREND_BONUS_CONFIG - config_snapshot_dict['RETRY_CONFIG'] = RETRY_CONFIG - config_snapshot_dict['CIRCUIT_BREAKER_CONFIG'] = CIRCUIT_BREAKER_CONFIG - config_snapshot_dict['WEBSOCKET_CONFIG'] = WEBSOCKET_CONFIG + config_snapshot_dict.update(serialize_config_safe(TRADING_CONFIG)) + # Ajouter les variables définies séparément avec sérialisation safe + config_snapshot_dict['RISK_CONFIG'] = serialize_config_safe(RISK_CONFIG) if RISK_CONFIG else {} + config_snapshot_dict['CONDITION_WEIGHTS'] = serialize_config_safe(CONDITION_WEIGHTS) if CONDITION_WEIGHTS else {} + config_snapshot_dict['TREND_BONUS_CONFIG'] = serialize_config_safe(TREND_BONUS_CONFIG) if TREND_BONUS_CONFIG else {} + config_snapshot_dict['RETRY_CONFIG'] = serialize_config_safe(RETRY_CONFIG) if RETRY_CONFIG else {} + config_snapshot_dict['CIRCUIT_BREAKER_CONFIG'] = serialize_config_safe(CIRCUIT_BREAKER_CONFIG) if CIRCUIT_BREAKER_CONFIG else {} + config_snapshot_dict['WEBSOCKET_CONFIG'] = serialize_config_safe(WEBSOCKET_CONFIG) if WEBSOCKET_CONFIG else {} + # Maintenant json.dumps() est safe config_snapshot = json.dumps(config_snapshot_dict) except Exception as e: - logger.warning(f"⚠️ Erreur préparation config_snapshot pour session: {e}") + logger.error(f"❌ Erreur préparation config_snapshot pour session: {e}", exc_info=True) config_snapshot = json.dumps({}) result = self._execute_query(query, (new_session_id, config_snapshot), fetch=True) @@ -242,12 +351,44 @@ def log_scan( ID du scan loggé ou None (None si batch mode) """ if not self.enabled: + logger.warning(f"⚠️ PostgreSQL DataLogger désactivé - scan non loggé pour {symbol}") return None # Obtenir ou créer session if not session_id: session_id = self.get_or_create_session() + # 🔥 FIX: Vérifier le prix AVANT d'ajouter au buffer (pour éviter les scans invalides) + market_data = scan_data.get('market_data', {}) + price = market_data.get('price') + if price is None: + # Fallback 1: Depuis scan_data directement + price = scan_data.get('price') + if price is None: + # Fallback 2: Depuis analysis_1m ou analysis_5m si disponible + analysis_1m = scan_data.get('analysis_1m', {}) + if isinstance(analysis_1m, dict): + price = analysis_1m.get('price') + if price is None: + analysis_5m = scan_data.get('analysis_5m', {}) + if isinstance(analysis_5m, dict): + price = analysis_5m.get('price') + # Extraire la valeur numérique si c'est un dict + if isinstance(price, dict): + price = price.get('price') or price.get('lastPrice') or price.get('close') or price.get('value') + # Vérifier que price est un nombre + if price is not None and not isinstance(price, (int, float)): + try: + price = float(price) + except (ValueError, TypeError): + logger.warning(f"⚠️ Prix invalide pour {symbol} dans log_scan (batch): {price} (type: {type(price)})") + price = None + + # Si le prix est toujours None, on ne peut pas insérer (contrainte NOT NULL) + if price is None: + logger.error(f"❌ Prix manquant pour {symbol} dans log_scan (batch), scan non ajouté au buffer") + return None + # 🔥 PHASE 3: Utiliser batch insert si activé if use_batch: with self.buffer_lock: @@ -256,6 +397,8 @@ def log_scan( 'symbol': symbol, 'scan_data': scan_data }) + buffer_size = len(self.scan_buffer) + logger.info(f"📝 Scan ajouté au buffer pour {symbol} (buffer size: {buffer_size}/{self.batch_size})") # Flush si buffer plein self._flush_buffers() return None # Pas d'ID immédiat en mode batch @@ -276,6 +419,7 @@ def log_scan( timestamp, session_id, symbol, scan_duration_ms, price, spread_pct, book_depth, balance_score, bid_vol, ask_vol, orderbook_imbalance_ratio, + recent_volume, vol5, vol15, scalability_score, -- Indicateurs 1m ema9_1m, ema21_1m, ema_diff_pct_1m, @@ -329,6 +473,7 @@ def log_scan( VALUES ( NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, @@ -346,13 +491,48 @@ def log_scan( RETURNING id """ + # 🔥 FIX: Récupérer le prix avec fallbacks multiples (pour éviter NULL) + price = market_data.get('price') + if price is None: + # Fallback 1: Depuis scan_data directement + price = scan_data.get('price') + if price is None: + # Fallback 2: Depuis analysis_1m ou analysis_5m si disponible + analysis_1m = scan_data.get('analysis_1m', {}) + if isinstance(analysis_1m, dict): + price = analysis_1m.get('price') + if price is None: + analysis_5m = scan_data.get('analysis_5m', {}) + if isinstance(analysis_5m, dict): + price = analysis_5m.get('price') + # Extraire la valeur numérique si c'est un dict + if isinstance(price, dict): + price = price.get('price') or price.get('lastPrice') or price.get('close') or price.get('value') + # Vérifier que price est un nombre + if price is not None and not isinstance(price, (int, float)): + try: + price = float(price) + except (ValueError, TypeError): + logger.warning(f"⚠️ Prix invalide pour {symbol} dans log_scan: {price} (type: {type(price)})") + price = None + + # Si le prix est toujours None, on ne peut pas insérer (contrainte NOT NULL) + if price is None: + logger.error(f"❌ Prix manquant pour {symbol} dans log_scan, insertion annulée") + return None + # Préparer les paramètres params = ( session_id, symbol, scan_data.get('scan_duration_ms'), - market_data.get('price'), market_data.get('spread_pct'), + price, market_data.get('spread_pct'), market_data.get('book_depth'), market_data.get('balance_score'), market_data.get('bid_vol'), market_data.get('ask_vol'), market_data.get('orderbook_imbalance_ratio'), + # Paramètres du scan de scalabilité + market_data.get('recent_volume') or scan_data.get('recent_volume') or scan_data.get('recentVolume'), + market_data.get('vol5') or scan_data.get('vol5'), + market_data.get('vol15') or scan_data.get('vol15'), + market_data.get('scalability_score') or scan_data.get('scalability_score') or scan_data.get('score'), # 1m indicators_1m.get('ema9'), indicators_1m.get('ema21'), @@ -504,6 +684,9 @@ def log_opportunity( tp_price = opportunity_data.get('tp_price') or opportunity_data.get('tp_suggested') sl_price = opportunity_data.get('sl_price') or opportunity_data.get('sl_suggested') tp_sl_mode = opportunity_data.get('tp_sl_mode', 'FIXE') + setup_score = opportunity_data.get('setup_score') + direction = opportunity_data.get('direction') + status = opportunity_data.get('status', 'PENDING') # S'assurer que les prix sont des nombres, pas des dicts if isinstance(entry_price, dict): @@ -514,16 +697,25 @@ def log_opportunity( sl_price = sl_price.get('price') or sl_price.get('value') if isinstance(tp_sl_mode, dict): tp_sl_mode = tp_sl_mode.get('mode') or 'FIXE' + # S'assurer que setup_score est un nombre, pas un dict + if isinstance(setup_score, dict): + setup_score = setup_score.get('score') or setup_score.get('value') or setup_score.get('totalScore') + # S'assurer que direction est une string, pas un dict + if isinstance(direction, dict): + direction = direction.get('direction') or direction.get('value') or str(direction) + # S'assurer que status est une string, pas un dict + if isinstance(status, dict): + status = status.get('status') or status.get('value') or 'PENDING' params = ( scan_id, session_id, symbol, - opportunity_data.get('status', 'PENDING'), - opportunity_data.get('direction'), - opportunity_data.get('setup_score'), + str(status) if status else 'PENDING', + str(direction) if direction else None, + float(setup_score) if setup_score is not None and not isinstance(setup_score, dict) else None, conditions_matched, # TEXT[] - liste de strings - entry_price, # entry_suggested - tp_price, # tp_suggested - sl_price, # sl_suggested + float(entry_price) if entry_price is not None and not isinstance(entry_price, dict) else None, # entry_suggested + float(tp_price) if tp_price is not None and not isinstance(tp_price, dict) else None, # tp_suggested + float(sl_price) if sl_price is not None and not isinstance(sl_price, dict) else None, # sl_suggested str(tp_sl_mode) if tp_sl_mode else 'FIXE' # tp_sl_mode ) @@ -632,7 +824,8 @@ def log_market_context( RETURNING id """ - now = datetime.now() + # 🔥 FIX BUG #3: Utiliser timezone.utc pour PostgreSQL TIMESTAMPTZ + now = datetime.now(timezone.utc) params = ( session_id, now.hour, @@ -697,7 +890,8 @@ def log_trade( session_id = self.get_or_create_session() try: - now = datetime.now() + # 🔥 FIX BUG #3: Utiliser timezone.utc pour PostgreSQL TIMESTAMPTZ + now = datetime.now(timezone.utc) timestamp_iso = now.isoformat() query = """ @@ -765,6 +959,7 @@ def log_trade( max_drawdown_pct, max_drawdown_usdt, -- Scalability entry_book_depth, entry_bid_vol, entry_ask_vol, entry_orderbook_imbalance, + entry_recent_volume, entry_vol5, entry_vol15, entry_scalability_score, -- Configuration snapshot config_snapshot, win @@ -790,7 +985,7 @@ def log_trade( %s, %s, %s, %s, %s, %s, %s, %s, - %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) RETURNING id """ @@ -801,12 +996,21 @@ def log_trade( exit_timestamp = trade_data.get('timestamp_exit') or (timestamp_iso if trade_data.get('exit_price') else None) # Calculer win (True si net_pnl_usdt > 0) - net_pnl_usdt = trade_data.get('net_pnl_usdt', 0) + net_pnl_usdt_raw = trade_data.get('net_pnl_usdt', 0) + net_pnl_usdt = _extract_numeric_value(net_pnl_usdt_raw) if net_pnl_usdt_raw is not None else 0 win = net_pnl_usdt > 0 if net_pnl_usdt is not None else None # Calculer tp_escalier_profits (somme des profits) tp_escalier_levels_hit = trade_data.get('tp_escalier_levels_hit', []) - tp_escalier_profits = sum(p.get('profit', 0) for p in tp_escalier_levels_hit) if tp_escalier_levels_hit else 0 + # 🔥 FIX: S'assurer que les profits sont des nombres, pas des dicts + tp_escalier_profits = 0 + if tp_escalier_levels_hit: + for p in tp_escalier_levels_hit: + if isinstance(p, dict): + profit_value = _extract_numeric_value(p.get('profit', 0)) + tp_escalier_profits += profit_value if profit_value is not None else 0 + elif isinstance(p, (int, float)): + tp_escalier_profits += float(p) # Extraire indicateurs d'entrée entry_indicators = trade_data.get('entry_indicators', {}) or {} @@ -815,6 +1019,36 @@ def log_trade( # Extraire indicateurs de sortie exit_indicators = trade_data.get('exit_indicators', {}) or {} + # 🔥 FIX: S'assurer que tous les indicateurs sont des valeurs numériques, pas des dicts + # Créer des copies "nettoyées" des indicateurs + entry_indicators_clean = {} + for key, value in entry_indicators.items(): + if isinstance(value, dict): + entry_indicators_clean[key] = _extract_numeric_value(value) + elif isinstance(value, (int, float)): + entry_indicators_clean[key] = float(value) + elif value is None: + entry_indicators_clean[key] = None + else: + # Essayer de convertir en float + entry_indicators_clean[key] = _extract_numeric_value(value) + + exit_indicators_clean = {} + for key, value in exit_indicators.items(): + if isinstance(value, dict): + exit_indicators_clean[key] = _extract_numeric_value(value) + elif isinstance(value, (int, float)): + exit_indicators_clean[key] = float(value) + elif value is None: + exit_indicators_clean[key] = None + else: + # Essayer de convertir en float + exit_indicators_clean[key] = _extract_numeric_value(value) + + # Utiliser les indicateurs nettoyés + entry_indicators = entry_indicators_clean + exit_indicators = exit_indicators_clean + # Calculer métriques temporelles try: if isinstance(entry_timestamp, str): @@ -851,11 +1085,18 @@ def log_trade( exit_hour = None exit_day = None + # 🔥 FIX: Extraire et nettoyer les prix AVANT les calculs (peuvent être des dicts) + entry_price_raw = trade_data.get('entry_price') + tp_price_raw = trade_data.get('tp_price') + sl_price_raw = trade_data.get('sl_price') + exit_price_raw = trade_data.get('exit_price') + + entry_price = _extract_numeric_value(entry_price_raw) if entry_price_raw is not None else None + tp_price = _extract_numeric_value(tp_price_raw) if tp_price_raw is not None else None + sl_price = _extract_numeric_value(sl_price_raw) if sl_price_raw is not None else None + exit_price = _extract_numeric_value(exit_price_raw) if exit_price_raw is not None else None + # Calculer risk_reward_ratio - entry_price = trade_data.get('entry_price') - tp_price = trade_data.get('tp_price') - sl_price = trade_data.get('sl_price') - exit_price = trade_data.get('exit_price') risk_reward_ratio = None if entry_price and tp_price and sl_price: if trade_data.get('direction') == 'LONG': @@ -918,24 +1159,50 @@ def log_trade( # Fallback sur max_pnl_reached / min_pnl_reached si pnl_history non disponible if max_favorable_excursion is None: - max_favorable_excursion = trade_data.get('max_pnl_reached') + max_favorable_excursion = _extract_numeric_value(trade_data.get('max_pnl_reached')) if max_adverse_excursion is None: - max_adverse_excursion = trade_data.get('min_pnl_reached') + max_adverse_excursion = _extract_numeric_value(trade_data.get('min_pnl_reached')) # Extraire scalability data entry_scalability = trade_data.get('entry_scalability', {}) or {} if not isinstance(entry_scalability, dict): entry_scalability = {} + # 🔥 FIX: S'assurer que tous les champs de scalability sont des valeurs numériques, pas des dicts + entry_scalability_clean = {} + for key, value in entry_scalability.items(): + if isinstance(value, dict): + entry_scalability_clean[key] = _extract_numeric_value(value) + elif isinstance(value, (int, float)): + entry_scalability_clean[key] = float(value) + elif value is None: + entry_scalability_clean[key] = None + else: + # Essayer de convertir en float + entry_scalability_clean[key] = _extract_numeric_value(value) + + # Utiliser les données de scalabilité nettoyées + entry_scalability = entry_scalability_clean + + # 🔥 FIX: Extraire et nettoyer size_usdt AVANT de calculer slippage_usdt + size_usdt_raw = trade_data.get('size_usdt') + size_usdt = _extract_numeric_value(size_usdt_raw) if size_usdt_raw is not None else None + # Calculer slippage_usdt - slippage_pct = trade_data.get('slippage', 0) or 0 - size_usdt = trade_data.get('size_usdt', 0) or 0 - slippage_usdt = (slippage_pct / 100) * size_usdt if slippage_pct and size_usdt else 0 + slippage_pct_raw = trade_data.get('slippage', 0) or 0 + slippage_pct = _extract_numeric_value(slippage_pct_raw) if slippage_pct_raw is not None else 0 + slippage_usdt = (slippage_pct / 100) * (size_usdt or 0) if slippage_pct and size_usdt else 0 # Extraire config_snapshot + # 🔥 FIX BUG #1: Utiliser serialize_config_safe() pour éviter les erreurs de sérialisation config_snapshot = trade_data.get('config_snapshot', {}) if config_snapshot: - config_snapshot = json.dumps(config_snapshot) + try: + config_snapshot_safe = serialize_config_safe(config_snapshot) + config_snapshot = json.dumps(config_snapshot_safe) + except Exception as e: + logger.error(f"❌ Erreur sérialisation config_snapshot pour trade: {e}", exc_info=True) + config_snapshot = None else: config_snapshot = None @@ -952,21 +1219,21 @@ def log_trade( entry_timestamp, exit_timestamp, session_id, opportunity_id, scan_log_id, trade_data.get('symbol'), trade_data.get('direction'), - trade_data.get('entry_price'), - trade_data.get('exit_price'), - trade_data.get('size_usdt'), - trade_data.get('tp_price'), # tp_price - trade_data.get('sl_price'), # sl_price - trade_data.get('gross_pnl_usdt', 0), - trade_data.get('gross_pnl_pct', 0), # pnl_pct (gross) - trade_data.get('gross_pnl_usdt', 0), # pnl_usdt (gross) - même valeur que gross_pnl_usdt (redondant mais présent dans le schéma) - trade_data.get('net_pnl_usdt', 0), - trade_data.get('net_pnl_pct', 0), - trade_data.get('fees', 0), # fees_usdt - trade_data.get('slippage', 0), # slippage_pct + entry_price, # entry_price (nettoyé) + exit_price, # exit_price (nettoyé) + size_usdt, # size_usdt (nettoyé) + tp_price, # tp_price (nettoyé) + sl_price, # sl_price (nettoyé) + _extract_numeric_value(trade_data.get('gross_pnl_usdt')) or 0, + _extract_numeric_value(trade_data.get('gross_pnl_pct')) or 0, # pnl_pct (gross) + _extract_numeric_value(trade_data.get('gross_pnl_usdt')) or 0, # pnl_usdt (gross) - même valeur que gross_pnl_usdt (redondant mais présent dans le schéma) + _extract_numeric_value(trade_data.get('net_pnl_usdt')) or 0, + _extract_numeric_value(trade_data.get('net_pnl_pct')) or 0, + _extract_numeric_value(trade_data.get('fees')) or 0, # fees_usdt + _extract_numeric_value(trade_data.get('slippage')) or 0, # slippage_pct slippage_usdt, # slippage_usdt trade_data.get('reason'), # exit_reason - trade_data.get('duration_seconds'), + _extract_numeric_value(trade_data.get('duration_seconds')), trade_data.get('tp_sl_mode'), trade_data.get('break_even_triggered', False), # break_even_set trade_data.get('break_even_triggered_at'), # break_even_triggered_at @@ -974,17 +1241,17 @@ def log_trade( trade_data.get('trailing_stop_triggered_at'), # trailing_stop_triggered_at trade_data.get('partial_tp_triggered', False), # partial_tp_executed trade_data.get('partial_tp_triggered_at'), # partial_tp_triggered_at - trade_data.get('partial_tp_profit'), # partial_tp_profit - trade_data.get('partial_tp_percent'), # partial_tp_percent + _extract_numeric_value(trade_data.get('partial_tp_profit')), # partial_tp_profit + _extract_numeric_value(trade_data.get('partial_tp_percent')), # partial_tp_percent len(tp_escalier_levels_hit), # tp_escalier_levels_executed (count) - tp_escalier_profits, # tp_escalier_profits (somme) + _extract_numeric_value(tp_escalier_profits) if tp_escalier_profits is not None else 0, # tp_escalier_profits (somme) # Early Invalidation trade_data.get('early_invalidation_triggered', False), # early_invalidation_triggered trade_data.get('early_invalidation_triggered_at'), # early_invalidation_triggered_at - trade_data.get('early_invalidation_threshold'), # early_invalidation_threshold - trade_data.get('early_invalidation_elapsed'), # early_invalidation_elapsed - trade_data.get('early_invalidation_atr_pct'), # early_invalidation_atr_pct - trade_data.get('early_invalidation_pnl_pct'), # early_invalidation_pnl_pct + _extract_numeric_value(trade_data.get('early_invalidation_threshold')), # early_invalidation_threshold + _extract_numeric_value(trade_data.get('early_invalidation_elapsed')), # early_invalidation_elapsed + _extract_numeric_value(trade_data.get('early_invalidation_atr_pct')), # early_invalidation_atr_pct + _extract_numeric_value(trade_data.get('early_invalidation_pnl_pct')), # early_invalidation_pnl_pct # Indicateurs d'entrée - RSI entry_indicators.get('rsi_1m'), entry_indicators.get('rsi_5m'), entry_indicators.get('rsi_prev_1m'), entry_indicators.get('rsi_prev_5m'), @@ -1014,9 +1281,9 @@ def log_trade( entry_indicators.get('volume_5m'), entry_indicators.get('volume_avg_5m'), entry_indicators.get('volume_ratio_5m'), entry_indicators.get('volume_spike_5m'), # Indicateurs d'entrée - Score et autres - entry_indicators.get('score'), # entry_score - entry_scalability.get('spread_pct'), # entry_spread_pct - entry_scalability.get('balance_score'), # entry_balance_score + _extract_numeric_value(entry_indicators.get('score')), # entry_score + _extract_numeric_value(entry_scalability.get('spread_pct')), # entry_spread_pct + _extract_numeric_value(entry_scalability.get('balance_score')), # entry_balance_score entry_conditions, # entry_conditions (TEXT[]) len(entry_conditions), # entry_condition_count # Métriques temporelles entry @@ -1026,27 +1293,36 @@ def log_trade( exit_indicators.get('macd_hist_1m'), exit_indicators.get('macd_hist_5m'), exit_indicators.get('adx_1m'), exit_indicators.get('adx_5m'), exit_indicators.get('atr_pct_1m'), exit_indicators.get('atr_pct_5m'), - exit_indicators.get('score'), # exit_score - exit_indicators.get('volume_ratio_1m'), exit_indicators.get('volume_ratio_5m'), - exit_indicators.get('spread_pct'), # exit_spread_pct - exit_indicators.get('balance_score'), # exit_balance_score - entry_to_exit_price_change_pct, + _extract_numeric_value(exit_indicators.get('score')), # exit_score + _extract_numeric_value(exit_indicators.get('volume_ratio_1m')), _extract_numeric_value(exit_indicators.get('volume_ratio_5m')), + _extract_numeric_value(exit_indicators.get('spread_pct')), # exit_spread_pct + _extract_numeric_value(exit_indicators.get('balance_score')), # exit_balance_score + _extract_numeric_value(entry_to_exit_price_change_pct) if entry_to_exit_price_change_pct is not None else None, # Métriques temporelles exit exit_hour, exit_day, # Métriques de position - max_favorable_excursion, max_adverse_excursion, - max_favorable_excursion_usdt, max_adverse_excursion_usdt, + _extract_numeric_value(max_favorable_excursion) if max_favorable_excursion is not None else None, + _extract_numeric_value(max_adverse_excursion) if max_adverse_excursion is not None else None, + _extract_numeric_value(max_favorable_excursion_usdt) if max_favorable_excursion_usdt is not None else None, + _extract_numeric_value(max_adverse_excursion_usdt) if max_adverse_excursion_usdt is not None else None, # Métriques de qualité - risk_reward_ratio, + _extract_numeric_value(risk_reward_ratio) if risk_reward_ratio is not None else None, None, # profit_factor (non calculé pour l'instant) # Métriques de performance additionnelles - entry_to_max_profit_price_change_pct, entry_to_max_loss_price_change_pct, - max_drawdown_pct, max_drawdown_usdt, + _extract_numeric_value(entry_to_max_profit_price_change_pct) if entry_to_max_profit_price_change_pct is not None else None, + _extract_numeric_value(entry_to_max_loss_price_change_pct) if entry_to_max_loss_price_change_pct is not None else None, + _extract_numeric_value(max_drawdown_pct) if max_drawdown_pct is not None else None, + _extract_numeric_value(max_drawdown_usdt) if max_drawdown_usdt is not None else None, # Scalability - entry_scalability.get('book_depth'), # entry_book_depth - entry_scalability.get('bid_vol'), # entry_bid_vol - entry_scalability.get('ask_vol'), # entry_ask_vol - entry_scalability.get('orderbook_imbalance'), # entry_orderbook_imbalance + _extract_numeric_value(entry_scalability.get('book_depth')), # entry_book_depth + _extract_numeric_value(entry_scalability.get('bid_vol')), # entry_bid_vol + _extract_numeric_value(entry_scalability.get('ask_vol')), # entry_ask_vol + _extract_numeric_value(entry_scalability.get('orderbook_imbalance')), # entry_orderbook_imbalance + # Paramètres du scan de scalabilité + _extract_numeric_value(entry_scalability.get('recent_volume') or entry_scalability.get('recentVolume')), # entry_recent_volume + _extract_numeric_value(entry_scalability.get('vol5')), # entry_vol5 + _extract_numeric_value(entry_scalability.get('vol15')), # entry_vol15 + _extract_numeric_value(entry_scalability.get('scalability_score') or entry_scalability.get('score')), # entry_scalability_score # Configuration snapshot config_snapshot, win @@ -1088,7 +1364,8 @@ def _flush_buffers(self, force: bool = False): if not self.enabled: return - now = datetime.now() + # 🔥 FIX BUG #3: Utiliser timezone.utc pour PostgreSQL TIMESTAMPTZ + now = datetime.now(timezone.utc) time_since_flush = (now - self.last_flush_time).total_seconds() should_flush = force or ( len(self.scan_buffer) >= self.batch_size or @@ -1100,22 +1377,32 @@ def _flush_buffers(self, force: bool = False): return with self.buffer_lock: + scans_count = len(self.scan_buffer) + opportunities_count = len(self.opportunity_buffer) + # Flush scans if self.scan_buffer: try: + logger.info(f"🔄 Flush {scans_count} scan(s) vers PostgreSQL (force={force})") self._batch_insert_scans(list(self.scan_buffer)) self.scan_buffer.clear() + logger.info(f"✅ {scans_count} scan(s) flushés avec succès") except Exception as e: logger.error(f"❌ Erreur flush scans: {e}") # Flush opportunities if self.opportunity_buffer: try: + logger.info(f"🔄 Flush {opportunities_count} opportunité(s) vers PostgreSQL (force={force})") self._batch_insert_opportunities(list(self.opportunity_buffer)) self.opportunity_buffer.clear() + logger.info(f"✅ {opportunities_count} opportunité(s) flushées avec succès") except Exception as e: logger.error(f"❌ Erreur flush opportunities: {e}") + if scans_count > 0 or opportunities_count > 0: + logger.info(f"📊 Flush buffers: {scans_count} scan(s), {opportunities_count} opportunité(s)") + self.last_flush_time = now def _batch_insert_scans(self, scans: List[Dict[str, Any]]): @@ -1149,13 +1436,48 @@ def _batch_insert_scans(self, scans: List[Dict[str, Any]]): patterns = scan_data.get('patterns', {}) market_data = scan_data.get('market_data', {}) + # 🔥 FIX: Récupérer le prix avec fallbacks multiples (pour éviter NULL) + price = market_data.get('price') + if price is None: + # Fallback 1: Depuis scan_data directement + price = scan_data.get('price') + if price is None: + # Fallback 2: Depuis analysis_1m ou analysis_5m si disponible + analysis_1m = scan_data.get('analysis_1m', {}) + if isinstance(analysis_1m, dict): + price = analysis_1m.get('price') + if price is None: + analysis_5m = scan_data.get('analysis_5m', {}) + if isinstance(analysis_5m, dict): + price = analysis_5m.get('price') + # Extraire la valeur numérique si c'est un dict + if isinstance(price, dict): + price = price.get('price') or price.get('lastPrice') or price.get('close') or price.get('value') + # Vérifier que price est un nombre + if price is not None and not isinstance(price, (int, float)): + try: + price = float(price) + except (ValueError, TypeError): + logger.warning(f"⚠️ Prix invalide pour {symbol} dans batch: {price} (type: {type(price)})") + price = None + + # Si le prix est toujours None, on ne peut pas insérer (contrainte NOT NULL) + if price is None: + logger.error(f"❌ Prix manquant pour {symbol} dans batch insert, scan ignoré") + continue + # Construire tuple de valeurs (même ordre que dans log_scan) value_tuple = ( session_id, symbol, scan_data.get('scan_duration_ms'), - market_data.get('price'), market_data.get('spread_pct'), + price, market_data.get('spread_pct'), market_data.get('book_depth'), market_data.get('balance_score'), market_data.get('bid_vol'), market_data.get('ask_vol'), market_data.get('orderbook_imbalance_ratio'), + # Paramètres du scan de scalabilité + market_data.get('recent_volume') or scan_data.get('recent_volume') or scan_data.get('recentVolume'), + market_data.get('vol5') or scan_data.get('vol5'), + market_data.get('vol15') or scan_data.get('vol15'), + market_data.get('scalability_score') or scan_data.get('scalability_score') or scan_data.get('score'), # 1m indicators indicators_1m.get('ema9'), indicators_1m.get('ema21'), indicators_1m.get('ema_diff_pct'), @@ -1223,6 +1545,7 @@ def _batch_insert_scans(self, scans: List[Dict[str, Any]]): 'session_id', 'symbol', 'scan_duration_ms', 'price', 'spread_pct', 'book_depth', 'balance_score', 'bid_vol', 'ask_vol', 'orderbook_imbalance_ratio', + 'recent_volume', 'vol5', 'vol15', 'scalability_score', 'ema9_1m', 'ema21_1m', 'ema_diff_pct_1m', 'rsi_1m', 'rsi_prev_1m', 'macd_1m', 'macd_signal_1m', 'macd_hist_1m', 'macd_hist_prev_1m', @@ -1296,8 +1619,30 @@ def _batch_insert_opportunities(self, opportunities: List[Dict[str, Any]]): values = [] for opp_item in opportunities: scan_id = opp_item['scan_id'] - session_id = opp_item['session_id'] symbol = opp_item['symbol'] + + # 🔥 FIX: Si scan_id = 0 (temporaire), essayer de le résoudre depuis scan_logs + if scan_id == 0 or scan_id is None: + # Chercher le scan_log_id correspondant dans scan_logs + # en utilisant symbol et timestamp récent (dernières 5 minutes) + resolve_query = """ + SELECT id FROM scan_logs + WHERE symbol = %s + AND is_opportunity = true + AND timestamp > NOW() - INTERVAL '5 minutes' + ORDER BY timestamp DESC + LIMIT 1 + """ + cursor.execute(resolve_query, (symbol,)) + result = cursor.fetchone() + if result: + scan_id = result[0] + logger.debug(f"✅ scan_id résolu pour {symbol}: {scan_id}") + else: + logger.warning(f"⚠️ Impossible de résoudre scan_id pour {symbol}, utilisation de 0") + scan_id = 0 + + session_id = opp_item['session_id'] opp_data = opp_item['opportunity_data'] # Convertir conditions_matched en liste de strings @@ -1317,6 +1662,9 @@ def _batch_insert_opportunities(self, opportunities: List[Dict[str, Any]]): tp_price = opp_data.get('tp_price') or opp_data.get('tp_suggested') sl_price = opp_data.get('sl_price') or opp_data.get('sl_suggested') tp_sl_mode = opp_data.get('tp_sl_mode', 'FIXE') + setup_score = opp_data.get('setup_score') + direction = opp_data.get('direction') + status = opp_data.get('status', 'PENDING') # S'assurer que les prix sont des nombres, pas des dicts if isinstance(entry_price, dict): @@ -1327,16 +1675,25 @@ def _batch_insert_opportunities(self, opportunities: List[Dict[str, Any]]): sl_price = sl_price.get('price') or sl_price.get('value') if isinstance(tp_sl_mode, dict): tp_sl_mode = tp_sl_mode.get('mode') or 'FIXE' + # S'assurer que setup_score est un nombre, pas un dict + if isinstance(setup_score, dict): + setup_score = setup_score.get('score') or setup_score.get('value') or setup_score.get('totalScore') + # S'assurer que direction est une string, pas un dict + if isinstance(direction, dict): + direction = direction.get('direction') or direction.get('value') or str(direction) + # S'assurer que status est une string, pas un dict + if isinstance(status, dict): + status = status.get('status') or status.get('value') or 'PENDING' value_tuple = ( scan_id, session_id, symbol, - opp_data.get('status', 'PENDING'), - opp_data.get('direction'), - opp_data.get('setup_score'), + str(status) if status else 'PENDING', + str(direction) if direction else None, + float(setup_score) if setup_score is not None and not isinstance(setup_score, dict) else None, conditions_matched, # TEXT[] - liste de strings - entry_price, # entry_suggested - tp_price, # tp_suggested - sl_price, # sl_suggested + float(entry_price) if entry_price is not None and not isinstance(entry_price, dict) else None, # entry_suggested + float(tp_price) if tp_price is not None and not isinstance(tp_price, dict) else None, # tp_suggested + float(sl_price) if sl_price is not None and not isinstance(sl_price, dict) else None, # sl_suggested str(tp_sl_mode) if tp_sl_mode else 'FIXE' # tp_sl_mode ) values.append(value_tuple) @@ -1369,9 +1726,20 @@ def _batch_insert_opportunities(self, opportunities: List[Dict[str, Any]]): def close(self): """Fermer le pool de connexions et flush les buffers""" + if not self.enabled: + return + # Flush final des buffers - if self.enabled: - self._flush_buffers(force=True) + logger.info("🔄 Flush final des buffers PostgreSQL...") + scans_before = len(self.scan_buffer) + opportunities_before = len(self.opportunity_buffer) + + self._flush_buffers(force=True) + + if scans_before > 0 or opportunities_before > 0: + logger.info(f"✅ Flush final terminé: {scans_before} scan(s) et {opportunities_before} opportunité(s) flushés") + else: + logger.info("✅ Flush final terminé: aucun élément en attente") if self.pool: try: diff --git a/core/scanner.py b/core/scanner.py index 8019deb4..951e6e4f 100644 --- a/core/scanner.py +++ b/core/scanner.py @@ -121,10 +121,11 @@ def calculate_score(self, pair: Dict, max_volume: float, max_depth: float) -> fl book_depth = pair.get('bookDepth', 0) balance_score = pair.get('balanceScore', 0) - # Filtres stricts - 🔥 v6.4.3: Spread entre 0.001% et 0.02% + # Filtres stricts - 🔥 v6.4.3: Spread entre 0.001% et 0.02% ET bookDepth > 0 # 🔥 FIX: Vérifier explicitement si spread est NaN car NaN > 0.02 retourne False # 🔥 FIX: Ajouter spread minimum de 0.001% pour éviter slippage nul - if math.isnan(spread) or spread <= 0.001 or spread > 0.02 or recent_volume < 100000 or balance_score < TRADING_CONFIG['balance_score_min']: + # 🔥 FIX BUG CRITIQUE: Filtrer book_depth <= 0 pour garantir slippage valide + if math.isnan(spread) or spread <= 0.001 or spread > 0.02 or book_depth <= 0 or recent_volume < 100000 or balance_score < TRADING_CONFIG['balance_score_min']: return 0.0 # Ratio volatilité/spread (plus élevé = mieux) diff --git a/core/simple_pg_logger.py b/core/simple_pg_logger.py new file mode 100644 index 00000000..f94e0b9f --- /dev/null +++ b/core/simple_pg_logger.py @@ -0,0 +1,241 @@ +""" +Simple PostgreSQL Logger - Sans batch, sans complexity +""" +import logging +import os +from datetime import datetime +from typing import Dict, Any, Optional +import json + +try: + import psycopg2 + PSYCOPG2_AVAILABLE = True +except ImportError: + PSYCOPG2_AVAILABLE = False + +logger = logging.getLogger(__name__) + +class SimplePGLogger: + """Logger PostgreSQL ultra-simple - Insert direct""" + + def __init__(self): + if not PSYCOPG2_AVAILABLE: + self.enabled = False + logger.warning("⚠️ psycopg2 non disponible") + return + + self.enabled = True + try: + self.conn = psycopg2.connect( + host=os.getenv('POSTGRES_HOST', 'localhost'), + port=int(os.getenv('POSTGRES_PORT', '5432')), + dbname=os.getenv('POSTGRES_DB', 'trade_cursor_ml'), + user=os.getenv('POSTGRES_USER', 'postgres'), + password=os.getenv('POSTGRES_PASSWORD', '') + ) + logger.info("✅ SimplePGLogger connecté") + except Exception as e: + logger.error(f"❌ Erreur connexion: {e}") + self.enabled = False + + def log_scan_simple(self, symbol: str, scan_data: Dict[str, Any]) -> bool: + """Logger un scan - Insert direct, pas de batch""" + if not self.enabled: + logger.warning(f"⚠️ SimplePGLogger désactivé pour {symbol}") + return False + + try: + # Vérifier que la connexion est toujours active + if self.conn.closed: + logger.error(f"❌ Connexion PostgreSQL fermée pour {symbol}") + self.enabled = False + return False + + cursor = self.conn.cursor() + + # Insert MINIMAL pour test + query = """ + INSERT INTO scan_logs ( + timestamp, symbol, price, + rsi_1m, score_total, is_opportunity + ) + VALUES (NOW(), %s, %s, %s, %s, %s) + """ + + indicators_1m = scan_data.get('indicators_1m', {}) or {} + + # Vérifier que le prix n'est pas None (contrainte NOT NULL) + price = scan_data.get('market_data', {}).get('price') + + # Extraire la valeur numérique si price est un dict + if isinstance(price, dict): + price = price.get('price') or price.get('lastPrice') or price.get('close') or price.get('value') + + # Vérifier que price est un nombre + if price is not None and not isinstance(price, (int, float)): + try: + price = float(price) + except (ValueError, TypeError): + logger.warning(f"⚠️ Prix invalide pour {symbol}: {price} (type: {type(price)})") + price = None + + if price is None: + logger.warning(f"⚠️ Prix manquant pour {symbol}, scan non loggé") + cursor.close() + return False + + # Récupérer RSI avec fallbacks multiples + rsi_1m = indicators_1m.get('rsi') + if rsi_1m is None: + # Fallback 1: Essayer depuis scan_data directement + rsi_1m = scan_data.get('rsi') or scan_data.get('rsi_1m') + if rsi_1m is not None: + logger.debug(f"🔍 DEBUG SimplePGLogger {symbol}: RSI récupéré depuis scan_data: {rsi_1m}") + # Fallback 2: Essayer depuis market_data + if rsi_1m is None: + market_data = scan_data.get('market_data', {}) + if isinstance(market_data, dict): + rsi_1m = market_data.get('rsi') or market_data.get('rsi_1m') + if rsi_1m is not None: + logger.debug(f"🔍 DEBUG SimplePGLogger {symbol}: RSI récupéré depuis market_data: {rsi_1m}") + # Fallback 3: Essayer depuis analysis_1m dans scan_data (si analysis a été passé) + if rsi_1m is None: + # Vérifier si scan_data contient analysis_1m (structure retournée par analyze_pair) + analysis_1m = scan_data.get('analysis_1m', {}) + if isinstance(analysis_1m, dict) and analysis_1m: + rsi_1m = analysis_1m.get('rsi') + if rsi_1m is not None: + logger.debug(f"🔍 DEBUG SimplePGLogger {symbol}: RSI récupéré depuis analysis_1m: {rsi_1m}") + + # Log de debug si RSI toujours manquant + if rsi_1m is None: + logger.debug(f"⚠️ DEBUG SimplePGLogger {symbol}: RSI non trouvé après tous les fallbacks. " + f"indicators_1m keys: {list(indicators_1m.keys()) if indicators_1m else 'None'}, " + f"scan_data keys: {list(scan_data.keys())[:10] if scan_data else 'None'}") + + # Récupérer score_total avec fallbacks + scores = scan_data.get('scores', {}) or {} + score_total = scores.get('score_total') + # Priorité 2: totalScore (nom utilisé dans analyzer.py) + if score_total is None: + score_total = scores.get('totalScore') or scan_data.get('totalScore') + # Priorité 3: score (nom alternatif) + if score_total is None: + score_total = scores.get('score') or scan_data.get('score') + # Fallback 1: Essayer depuis scan_data directement + if score_total is None: + score_total = scan_data.get('score_total') or scan_data.get('totalScore') or scan_data.get('score') + if score_total is not None: + logger.debug(f"🔍 DEBUG SimplePGLogger {symbol}: score_total récupéré depuis scan_data: {score_total}") + # Fallback 2: Essayer depuis market_data + if score_total is None: + market_data = scan_data.get('market_data', {}) + if isinstance(market_data, dict): + score_total = market_data.get('score_total') or market_data.get('totalScore') or market_data.get('score') + if score_total is not None: + logger.debug(f"🔍 DEBUG SimplePGLogger {symbol}: score_total récupéré depuis market_data: {score_total}") + # Fallback 3: Essayer depuis analysis_1m dans scan_data (si analysis a été passé) + if score_total is None: + analysis_1m = scan_data.get('analysis_1m', {}) + if isinstance(analysis_1m, dict) and analysis_1m: + score_total = analysis_1m.get('score_total') or analysis_1m.get('totalScore') or analysis_1m.get('score') + if score_total is not None: + logger.debug(f"🔍 DEBUG SimplePGLogger {symbol}: score_total récupéré depuis analysis_1m: {score_total}") + # Fallback 4: Essayer depuis analysis_5m + if score_total is None: + analysis_5m = scan_data.get('analysis_5m', {}) + if isinstance(analysis_5m, dict) and analysis_5m: + score_total = analysis_5m.get('score_total') or analysis_5m.get('totalScore') or analysis_5m.get('score') + if score_total is not None: + logger.debug(f"🔍 DEBUG SimplePGLogger {symbol}: score_total récupéré depuis analysis_5m: {score_total}") + + # Fallback 5: Essayer long_score ou short_score depuis scan_data (retournés par analyzer.py pour les rejets) + if score_total is None: + long_score = scan_data.get('long_score') + short_score = scan_data.get('short_score') + # Utiliser le score le plus élevé, ou celui qui est disponible + if long_score is not None and short_score is not None: + score_total = max(long_score, short_score) + elif long_score is not None: + score_total = long_score + elif short_score is not None: + score_total = short_score + if score_total is not None: + logger.debug(f"🔍 DEBUG SimplePGLogger {symbol}: score_total récupéré depuis long_score/short_score: {score_total}") + + # Fallback 6: Essayer long_score ou short_score depuis analysis_1m ou analysis_5m + if score_total is None: + analysis_1m = scan_data.get('analysis_1m', {}) + analysis_5m = scan_data.get('analysis_5m', {}) + if isinstance(analysis_1m, dict) and analysis_1m: + long_score = analysis_1m.get('long_score') + short_score = analysis_1m.get('short_score') + if long_score is not None and short_score is not None: + score_total = max(long_score, short_score) + elif long_score is not None: + score_total = long_score + elif short_score is not None: + score_total = short_score + if score_total is None and isinstance(analysis_5m, dict) and analysis_5m: + long_score = analysis_5m.get('long_score') + short_score = analysis_5m.get('short_score') + if long_score is not None and short_score is not None: + score_total = max(long_score, short_score) + elif long_score is not None: + score_total = long_score + elif short_score is not None: + score_total = short_score + if score_total is not None: + logger.debug(f"🔍 DEBUG SimplePGLogger {symbol}: score_total récupéré depuis long_score/short_score dans analysis_1m/5m: {score_total}") + + # Log de debug si score_total toujours manquant + if score_total is None: + logger.debug(f"⚠️ DEBUG SimplePGLogger {symbol}: score_total non trouvé après tous les fallbacks. " + f"scores keys: {list(scores.keys()) if scores else 'None'}, " + f"scan_data keys: {list(scan_data.keys())[:15] if scan_data else 'None'}") + + params = ( + symbol, + price, + rsi_1m, # Peut être None, ce qui est acceptable pour la base de données + score_total, # Peut être None, ce qui est acceptable pour la base de données + scan_data.get('is_opportunity', False) + ) + + logger.debug(f"🔍 SimplePGLogger: Insert pour {symbol} avec params: (symbol={symbol}, price={price}, rsi_1m={rsi_1m}, score_total={score_total}, is_opportunity={scan_data.get('is_opportunity', False)})") + cursor.execute(query, params) + + self.conn.commit() + cursor.close() + + logger.info(f"✅ Scan loggé: {symbol}") + return True + + except Exception as e: + logger.error(f"❌ Erreur log_scan pour {symbol}: {e}") + import traceback + logger.debug(f"Traceback: {traceback.format_exc()}") + # 🔥 FIX: Rollback en cas d'erreur pour éviter que la transaction reste en état d'erreur + try: + if hasattr(self, 'conn') and not self.conn.closed: + self.conn.rollback() + logger.debug(f"🔄 Rollback effectué pour {symbol}") + except Exception as rollback_error: + logger.error(f"❌ Erreur rollback: {rollback_error}") + # Essayer de reconnecter si la connexion est fermée + try: + if hasattr(self, 'conn') and (self.conn.closed if hasattr(self.conn, 'closed') else False): + logger.info("🔄 Tentative de reconnexion...") + self.conn = psycopg2.connect( + host=os.getenv('POSTGRES_HOST', 'localhost'), + port=int(os.getenv('POSTGRES_PORT', '5432')), + dbname=os.getenv('POSTGRES_DB', 'trade_cursor_ml'), + user=os.getenv('POSTGRES_USER', 'postgres'), + password=os.getenv('POSTGRES_PASSWORD', '') + ) + logger.info("✅ Reconnexion réussie") + except Exception as reconnect_error: + logger.error(f"❌ Erreur reconnexion: {reconnect_error}") + self.enabled = False + return False + diff --git a/database/apply_migration_scalability.ps1 b/database/apply_migration_scalability.ps1 new file mode 100644 index 00000000..237b067b --- /dev/null +++ b/database/apply_migration_scalability.ps1 @@ -0,0 +1,86 @@ +# Script PowerShell pour appliquer la migration des paramètres de scalabilité +# Usage: .\apply_migration_scalability.ps1 + +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host "Migration: Paramètres de Scalabilité" -ForegroundColor Cyan +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host "" + +# Vérifier si le fichier de migration existe +$migrationFile = "database\migration_add_scalability_params.sql" +if (-not (Test-Path $migrationFile)) { + Write-Host "❌ Erreur: Fichier de migration non trouvé: $migrationFile" -ForegroundColor Red + Write-Host " Assurez-vous d'être dans le répertoire du projet." -ForegroundColor Yellow + exit 1 +} + +Write-Host "✅ Fichier de migration trouvé: $migrationFile" -ForegroundColor Green +Write-Host "" + +# Demander le mot de passe PostgreSQL +$password = Read-Host "Entrez le mot de passe PostgreSQL pour l'utilisateur 'postgres'" -AsSecureString +$passwordPlain = [Runtime.InteropServices.Marshal]::PtrToStringAuto( + [Runtime.InteropServices.Marshal]::SecureStringToBSTR($password) +) + +# Définir la variable d'environnement PGPASSWORD +$env:PGPASSWORD = $passwordPlain + +Write-Host "" +Write-Host "📊 Application de la migration..." -ForegroundColor Yellow +Write-Host "" + +# Exécuter la migration +try { + $result = & psql -U postgres -d trade_cursor_ml -f $migrationFile 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Host "" + Write-Host "✅ Migration appliquée avec succès!" -ForegroundColor Green + Write-Host "" + + # Vérifier que les colonnes ont été ajoutées + Write-Host "🔍 Vérification des colonnes ajoutées..." -ForegroundColor Yellow + $checkQuery = @" +SELECT + CASE WHEN EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'scan_logs' AND column_name = 'recent_volume' + ) THEN '✅ recent_volume' ELSE '❌ recent_volume' END as scan_logs_recent_volume, + CASE WHEN EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'scan_logs' AND column_name = 'vol5' + ) THEN '✅ vol5' ELSE '❌ vol5' END as scan_logs_vol5, + CASE WHEN EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'scan_logs' AND column_name = 'scalability_score' + ) THEN '✅ scalability_score' ELSE '❌ scalability_score' END as scan_logs_score, + CASE WHEN EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'trades' AND column_name = 'entry_recent_volume' + ) THEN '✅ entry_recent_volume' ELSE '❌ entry_recent_volume' END as trades_recent_volume, + CASE WHEN EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'trades' AND column_name = 'entry_scalability_score' + ) THEN '✅ entry_scalability_score' ELSE '❌ entry_scalability_score' END as trades_score; +"@ + + $checkResult = & psql -U postgres -d trade_cursor_ml -c $checkQuery 2>&1 + Write-Host $checkResult + + } else { + Write-Host "" + Write-Host "❌ Erreur lors de l'application de la migration" -ForegroundColor Red + Write-Host $result + } +} catch { + Write-Host "" + Write-Host "❌ Erreur: $_" -ForegroundColor Red +} finally { + # Nettoyer la variable d'environnement + Remove-Item Env:\PGPASSWORD -ErrorAction SilentlyContinue +} + +Write-Host "" +Write-Host "==========================================" -ForegroundColor Cyan + diff --git a/database/diagnostic_opportunities.sql b/database/diagnostic_opportunities.sql new file mode 100644 index 00000000..bdca2845 --- /dev/null +++ b/database/diagnostic_opportunities.sql @@ -0,0 +1,67 @@ +-- ============================================================================ +-- DIAGNOSTIC : Pourquoi 0 opportunités dans la dernière heure ? +-- ============================================================================ + +-- 1. Vérifier combien de scans ont is_opportunity = true dans la dernière heure +SELECT + 'Scans avec is_opportunity = true (dernière heure)' as type, + COUNT(*) as count +FROM scan_logs +WHERE timestamp > NOW() - INTERVAL '1 hour' + AND is_opportunity = true; + +-- 2. Vérifier toutes les opportunités (pas seulement dernière heure) +SELECT + 'Total opportunités (toutes périodes)' as type, + COUNT(*) as count +FROM opportunities; + +-- 3. Vérifier les opportunités dans la dernière heure +SELECT + 'Opportunités (dernière heure)' as type, + COUNT(*) as count +FROM opportunities +WHERE timestamp > NOW() - INTERVAL '1 hour'; + +-- 4. Vérifier les opportunités dans les dernières 24h +SELECT + 'Opportunités (24 dernières heures)' as type, + COUNT(*) as count +FROM opportunities +WHERE timestamp > NOW() - INTERVAL '24 hours'; + +-- 5. Vérifier les scans avec is_opportunity = true mais pas d'entrée dans opportunities +SELECT + s.symbol, + s.timestamp, + s.is_opportunity, + s.opportunity_direction, + s.score_total, + o.id as opportunity_id +FROM scan_logs s +LEFT JOIN opportunities o ON s.id = o.scan_log_id +WHERE s.timestamp > NOW() - INTERVAL '1 hour' + AND s.is_opportunity = true +ORDER BY s.timestamp DESC +LIMIT 10; + +-- 6. Vérifier le statut des opportunités récentes +SELECT + status, + COUNT(*) as count +FROM opportunities +WHERE timestamp > NOW() - INTERVAL '24 hours' +GROUP BY status; + +-- 7. Vérifier les dernières opportunités créées +SELECT + id, + symbol, + direction, + status, + timestamp, + scan_log_id +FROM opportunities +ORDER BY timestamp DESC +LIMIT 10; + diff --git a/database/migration_add_scalability_params.sql b/database/migration_add_scalability_params.sql new file mode 100644 index 00000000..7128c437 --- /dev/null +++ b/database/migration_add_scalability_params.sql @@ -0,0 +1,113 @@ +-- ============================================================================ +-- MIGRATION: Ajout des paramètres du scan de scalabilité +-- ============================================================================ +-- Date: 2025-11-13 +-- Description: Ajoute les colonnes manquantes pour logger les paramètres +-- du scan de scalabilité (recentVolume, vol5, vol15, score) +-- ============================================================================ + +-- ============================================================================ +-- TABLE scan_logs +-- ============================================================================ + +-- Ajouter recent_volume (Volume des 5 dernières bougies 1m) +ALTER TABLE scan_logs ADD COLUMN IF NOT EXISTS recent_volume FLOAT; + +-- Ajouter vol5 (Volatilité sur 5 périodes en %) +ALTER TABLE scan_logs ADD COLUMN IF NOT EXISTS vol5 FLOAT; + +-- Ajouter vol15 (Volatilité sur 15 périodes en %) +ALTER TABLE scan_logs ADD COLUMN IF NOT EXISTS vol15 FLOAT; + +-- Ajouter scalability_score (Score de scalabilité final) +ALTER TABLE scan_logs ADD COLUMN IF NOT EXISTS scalability_score FLOAT; + +-- Commentaires +COMMENT ON COLUMN scan_logs.recent_volume IS 'Volume total des 5 dernières bougies 1m (utilisé pour calculer le score de scalabilité)'; +COMMENT ON COLUMN scan_logs.vol5 IS 'Volatilité sur 5 périodes (écart-type normalisé en %)'; +COMMENT ON COLUMN scan_logs.vol15 IS 'Volatilité sur 15 périodes (écart-type normalisé en %)'; +COMMENT ON COLUMN scan_logs.scalability_score IS 'Score de scalabilité final calculé par ScalabilityScanner'; + +-- ============================================================================ +-- TABLE trades +-- ============================================================================ + +-- Entry parameters +ALTER TABLE trades ADD COLUMN IF NOT EXISTS entry_recent_volume FLOAT; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS entry_vol5 FLOAT; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS entry_vol15 FLOAT; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS entry_scalability_score FLOAT; + +-- Exit parameters (optionnel, pour analyse comparative) +ALTER TABLE trades ADD COLUMN IF NOT EXISTS exit_recent_volume FLOAT; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS exit_vol5 FLOAT; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS exit_vol15 FLOAT; + +-- Commentaires +COMMENT ON COLUMN trades.entry_recent_volume IS 'Volume des 5 dernières bougies au moment de l''entrée'; +COMMENT ON COLUMN trades.entry_vol5 IS 'Volatilité 5 périodes au moment de l''entrée (%)'; +COMMENT ON COLUMN trades.entry_vol15 IS 'Volatilité 15 périodes au moment de l''entrée (%)'; +COMMENT ON COLUMN trades.entry_scalability_score IS 'Score de scalabilité au moment de l''entrée'; +COMMENT ON COLUMN trades.exit_recent_volume IS 'Volume des 5 dernières bougies au moment de la sortie'; +COMMENT ON COLUMN trades.exit_vol5 IS 'Volatilité 5 périodes au moment de la sortie (%)'; +COMMENT ON COLUMN trades.exit_vol15 IS 'Volatilité 15 périodes au moment de la sortie (%)'; + +-- ============================================================================ +-- INDEX pour performance (optionnel) +-- ============================================================================ + +-- Index sur scalability_score pour analyse ML +CREATE INDEX IF NOT EXISTS idx_scan_scalability_score ON scan_logs(scalability_score) + WHERE scalability_score IS NOT NULL; + +-- Index sur entry_scalability_score pour corrélation avec performance +CREATE INDEX IF NOT EXISTS idx_trade_entry_scalability_score ON trades(entry_scalability_score) + WHERE entry_scalability_score IS NOT NULL; + +-- ============================================================================ +-- VÉRIFICATION +-- ============================================================================ + +-- Vérifier que les colonnes ont été ajoutées +DO $$ +BEGIN + -- Vérifier scan_logs + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'scan_logs' AND column_name = 'recent_volume' + ) THEN + RAISE EXCEPTION 'Colonne recent_volume non trouvée dans scan_logs'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'scan_logs' AND column_name = 'vol5' + ) THEN + RAISE EXCEPTION 'Colonne vol5 non trouvée dans scan_logs'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'scan_logs' AND column_name = 'scalability_score' + ) THEN + RAISE EXCEPTION 'Colonne scalability_score non trouvée dans scan_logs'; + END IF; + + -- Vérifier trades + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'trades' AND column_name = 'entry_recent_volume' + ) THEN + RAISE EXCEPTION 'Colonne entry_recent_volume non trouvée dans trades'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'trades' AND column_name = 'entry_scalability_score' + ) THEN + RAISE EXCEPTION 'Colonne entry_scalability_score non trouvée dans trades'; + END IF; + + RAISE NOTICE '✅ Migration réussie: Toutes les colonnes ont été ajoutées'; +END $$; + diff --git a/frontend/src/lib/components/VariablesPanel.svelte b/frontend/src/lib/components/VariablesPanel.svelte index 66f3fb53..c79979fe 100644 --- a/frontend/src/lib/components/VariablesPanel.svelte +++ b/frontend/src/lib/components/VariablesPanel.svelte @@ -81,6 +81,10 @@ let completeConfig = null; let loadingCompleteConfig = false; let completeConfigError = null; + + // Variables pour export Excel et reset DB + let exportingExcel = false; + let resettingDB = false; // Auto-ajustement sliders Escalier pour que la somme = 100% function autoAdjustEscalierSize(changedLevel) { @@ -502,6 +506,80 @@ } } + // 🔥 Export Excel du datalogger + async function exportExcel() { + if (exportingExcel) return; + + exportingExcel = true; + saveMessage = '⏳ Export Excel en cours...'; + + try { + const response = await fetch('/api/datalogger/export/excel'); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Erreur export Excel'); + } + + // Télécharger le fichier + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `datalogger_export_${new Date().toISOString().split('T')[0]}.xlsx`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + saveMessage = '✅ Export Excel réussi !'; + setTimeout(() => saveMessage = '', 3000); + } catch (error: any) { + saveMessage = `❌ Erreur export Excel: ${error.message}`; + setTimeout(() => saveMessage = '', 5000); + } finally { + exportingExcel = false; + } + } + + // 🔥 Reset de la base de données PostgreSQL + async function resetDatabase() { + if (resettingDB) return; + + // Confirmation avant reset + if (!confirm('⚠️ ATTENTION: Cette opération va supprimer TOUTES les données de la base PostgreSQL (scans, opportunities, trades, etc.).\n\nÊtes-vous sûr de vouloir continuer ?')) { + return; + } + + // Double confirmation + if (!confirm('⚠️ DERNIÈRE CONFIRMATION: Toutes les données seront PERDUES de manière irréversible.\n\nConfirmez-vous le reset ?')) { + return; + } + + resettingDB = true; + saveMessage = '⏳ Reset de la base de données en cours...'; + + try { + const response = await fetch('/api/datalogger/reset', { + method: 'DELETE' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Erreur reset DB'); + } + + const result = await response.json(); + saveMessage = `✅ Base de données resetée: ${result.total_deleted} enregistrements supprimés`; + setTimeout(() => saveMessage = '', 5000); + } catch (error: any) { + saveMessage = `❌ Erreur reset DB: ${error.message}`; + setTimeout(() => saveMessage = '', 5000); + } finally { + resettingDB = false; + } + } + // Fonction pour logger les changements (pour historique backend) async function logConfigChange(key: string, change: any) { // 🔥 MIGRATION COMPLÈTE: Envoyer log via WebSocket natif uniquement @@ -616,6 +694,12 @@ + + @@ -2171,6 +2255,48 @@ transform: translateY(-2px); } + .btn-export { + background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%); + color: #fff; + border: none; + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; + } + + .btn-export:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(74, 144, 226, 0.4); + } + + .btn-export:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .btn-danger { + background: linear-gradient(135deg, #ff4444 0%, #cc0000 100%); + color: #fff; + border: none; + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; + } + + .btn-danger:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(255, 68, 68, 0.4); + } + + .btn-danger:disabled { + opacity: 0.6; + cursor: not-allowed; + } + .save-message { padding: 12px 16px; border-radius: 8px; diff --git a/frontend/src/lib/stores/position.js b/frontend/src/lib/stores/position.js index 18a51c63..fa0fcec7 100644 --- a/frontend/src/lib/stores/position.js +++ b/frontend/src/lib/stores/position.js @@ -13,17 +13,19 @@ export const pnlColor = derived(activePosition, $pos => { return $pos.pnl >= 0 ? '#00ff88' : '#ff4444'; }); -// Computed: SL distance +// Computed: SL distance (toujours positif, indique la distance en %) export const slDistance = derived(activePosition, $pos => { if (!$pos || !$pos.sl || !$pos.current_price) return null; - const distance = (($pos.sl - $pos.current_price) / $pos.current_price) * 100; + // 🔥 FIX BUG: Calcul absolu de la distance, pas de signe négatif + const distance = Math.abs((($pos.sl - $pos.current_price) / $pos.current_price) * 100); return distance.toFixed(2); }); -// Computed: TP distance +// Computed: TP distance (toujours positif, indique la distance en %) export const tpDistance = derived(activePosition, $pos => { if (!$pos || !$pos.tp || !$pos.current_price) return null; - const distance = (($pos.tp - $pos.current_price) / $pos.current_price) * 100; + // 🔥 FIX BUG: Calcul absolu de la distance, pas de signe négatif + const distance = Math.abs((($pos.tp - $pos.current_price) / $pos.current_price) * 100); return distance.toFixed(2); }); diff --git a/frontend/src/lib/stores/stats.js b/frontend/src/lib/stores/stats.js index a53ee795..390a2f0b 100644 --- a/frontend/src/lib/stores/stats.js +++ b/frontend/src/lib/stores/stats.js @@ -88,12 +88,21 @@ export const avgPnl = derived(stats, $stats => { return ($stats.total_pnl_usdt / $stats.total_trades).toFixed(2); }); -// Computed: Profit Factor -export const profitFactor = derived(stats, $stats => { - if (!$stats.best_trade || !$stats.worst_trade) return null; - const grossProfit = $stats.wins * ($stats.best_trade?.pnl_usdt || 0); - const grossLoss = Math.abs($stats.losses * ($stats.worst_trade?.pnl_usdt || 0)); - if (grossLoss === 0) return grossProfit > 0 ? '∞' : '0'; +// Computed: Profit Factor (Somme profits / |Somme pertes|) +export const profitFactor = derived(tradeHistory, $trades => { + if ($trades.length === 0) return '0.00'; + + // 🔥 FIX BUG CRITIQUE: Profit Factor = Σ(profits) / |Σ(losses)| + // Ancienne formule incorrecte: (wins × best_trade) / (losses × worst_trade) + const grossProfit = $trades + .filter(t => (t.net_pnl_usdt || t.pnl_usdt || 0) > 0) + .reduce((sum, t) => sum + (t.net_pnl_usdt || t.pnl_usdt || 0), 0); + + const grossLoss = Math.abs($trades + .filter(t => (t.net_pnl_usdt || t.pnl_usdt || 0) < 0) + .reduce((sum, t) => sum + (t.net_pnl_usdt || t.pnl_usdt || 0), 0)); + + if (grossLoss === 0) return grossProfit > 0 ? '∞' : '0.00'; return (grossProfit / grossLoss).toFixed(2); }); diff --git a/main.py b/main.py index 5cc5217c..fced4f48 100644 --- a/main.py +++ b/main.py @@ -403,6 +403,9 @@ def load_trade_history(): notification_manager = None session_id = None # ID unique de cette session +# 🔥 Simple Logger: Logger ultra-simple sans batch pour debugging +_simple_logger = None + # 🔥 FIX: Lock pour éviter les ouvertures multiples de positions position_lock = asyncio.Lock() @@ -703,7 +706,12 @@ async def scanner_loop_callback(): 'depth': book_depth, 'balance': balance_score, 'bid_vol': bid_vol, - 'ask_vol': ask_vol + 'ask_vol': ask_vol, + # 🔥 FIX: Ajouter les paramètres du scan de scalabilité + 'recent_volume': pair.get('recentVolume'), + 'vol5': pair.get('vol5'), + 'vol15': pair.get('vol15'), + 'scalability_score': pair.get('score'), } logger.info(f"💹 Données scalabilité récupérées depuis top_pairs: spread={spread_value}%, depth={book_depth}, balance={balance_score}") @@ -736,7 +744,12 @@ async def scanner_loop_callback(): 'depth': orderbook_depth or setup.get('orderbook_depth', 0) or (setup.get('bid_vol', 0) + setup.get('ask_vol', 0)), 'balance': setup.get('orderbook_balance', 1.0) or setup.get('orderbook_check', {}).get('balance', 1.0), 'bid_vol': setup.get('bid_vol'), - 'ask_vol': setup.get('ask_vol') + 'ask_vol': setup.get('ask_vol'), + # 🔥 FIX: Ajouter les paramètres du scan de scalabilité depuis setup si disponibles + 'recent_volume': setup.get('recent_volume') or setup.get('recentVolume'), + 'vol5': setup.get('vol5'), + 'vol15': setup.get('vol15'), + 'scalability_score': setup.get('scalability_score') or setup.get('score'), } logger.info(f"💹 Données scalabilité depuis setup: spread={scalability_data.get('spread_pct')}%, depth={scalability_data.get('depth')}") else: @@ -927,12 +940,16 @@ async def scanner_loop_callback(): async def scan_pair_for_setup(symbol: str): """Scanner une paire pour trouver un setup""" + global _simple_logger # 🔥 Simple Logger: Accès à la variable globale init_instances() if not analyzer: logger.warning(f"Analyzer non disponible pour {symbol}") return None + # 🔥 PHASE 3: Mesurer la durée du scan + scan_start_time = time.time() + # 🔥 FIX: Ajouter log pour voir que l'analyse démarre logger.info(f"🔍 Analyse {symbol}...") @@ -980,33 +997,68 @@ async def scan_pair_for_setup(symbol: str): available_keys = [k for k in analysis.keys() if k not in ['symbol', 'direction', 'entry', 'sl', 'tp', 'price', 'signals', 'condition_types', 'totalScore', 'reason', 'reject_category']] logger.info(f"🔍 DEBUG analysis keys disponibles pour indicators_1m: {available_keys[:20]}") - indicators_1m = { - 'rsi': analysis.get('rsi'), - 'rsi_prev': analysis.get('rsi_prev'), - 'macd': analysis.get('macd'), - 'macd_signal': analysis.get('macd_signal'), - 'macd_hist': analysis.get('macd_hist'), - 'macd_hist_prev': analysis.get('macd_hist_prev'), - 'adx': analysis.get('adx'), - 'di_plus': analysis.get('di_plus'), - 'di_minus': analysis.get('di_minus'), - 'di_gap': analysis.get('di_gap'), - 'ema9': analysis.get('ema9'), - 'ema21': analysis.get('ema21'), - 'ema_diff_pct': analysis.get('ema_diff_pct'), - 'atr': analysis.get('atr'), - 'atr_pct': analysis.get('atr_pct'), - 'bb_upper': analysis.get('bb_upper'), - 'bb_middle': analysis.get('bb_middle'), - 'bb_lower': analysis.get('bb_lower'), - 'bb_width': analysis.get('bb_width'), - 'bb_distance_to_lower': analysis.get('bb_distance_to_lower'), - 'bb_distance_to_upper': analysis.get('bb_distance_to_upper'), - 'volume': analysis.get('volume'), - 'volume_avg': analysis.get('volume_avg'), - 'volume_ratio': analysis.get('volume_ratio') or analysis.get('volumeSpike'), - 'volume_spike': analysis.get('volume_spike'), - } + # 🔥 PRIORITÉ 1: Vérifier si analysis contient analysis_1m (retourné par analyze_pair quand aucun setup n'est trouvé) + analysis_1m = analysis.get('analysis_1m', {}) + if isinstance(analysis_1m, dict) and analysis_1m: + # Extraire les indicateurs depuis analysis_1m + indicators_1m = { + 'rsi': analysis_1m.get('rsi'), + 'rsi_prev': analysis_1m.get('rsi_prev'), + 'macd': analysis_1m.get('macd'), + 'macd_signal': analysis_1m.get('macd_signal'), + 'macd_hist': analysis_1m.get('macd_hist'), + 'macd_hist_prev': analysis_1m.get('macd_hist_prev'), + 'adx': analysis_1m.get('adx'), + 'di_plus': analysis_1m.get('di_plus'), + 'di_minus': analysis_1m.get('di_minus'), + 'di_gap': analysis_1m.get('di_gap'), + 'ema9': analysis_1m.get('ema9'), + 'ema21': analysis_1m.get('ema21'), + 'ema_diff_pct': analysis_1m.get('ema_diff_pct'), + 'atr': analysis_1m.get('atr'), + 'atr_pct': analysis_1m.get('atr_pct'), + 'bb_upper': analysis_1m.get('bb_upper'), + 'bb_middle': analysis_1m.get('bb_middle'), + 'bb_lower': analysis_1m.get('bb_lower'), + 'bb_width': analysis_1m.get('bb_width'), + 'bb_distance_to_lower': analysis_1m.get('bb_distance_to_lower'), + 'bb_distance_to_upper': analysis_1m.get('bb_distance_to_upper'), + 'volume': analysis_1m.get('volume'), + 'volume_avg': analysis_1m.get('volume_avg'), + 'volume_ratio': analysis_1m.get('volume_ratio') or analysis_1m.get('volumeSpike'), + 'volume_spike': analysis_1m.get('volume_spike'), + } + logger.debug(f"🔍 DEBUG {symbol}: indicators_1m construit depuis analysis_1m") + else: + # 🔥 PRIORITÉ 2: Chercher directement dans analysis (pour les setups valides) + indicators_1m = { + 'rsi': analysis.get('rsi'), + 'rsi_prev': analysis.get('rsi_prev'), + 'macd': analysis.get('macd'), + 'macd_signal': analysis.get('macd_signal'), + 'macd_hist': analysis.get('macd_hist'), + 'macd_hist_prev': analysis.get('macd_hist_prev'), + 'adx': analysis.get('adx'), + 'di_plus': analysis.get('di_plus'), + 'di_minus': analysis.get('di_minus'), + 'di_gap': analysis.get('di_gap'), + 'ema9': analysis.get('ema9'), + 'ema21': analysis.get('ema21'), + 'ema_diff_pct': analysis.get('ema_diff_pct'), + 'atr': analysis.get('atr'), + 'atr_pct': analysis.get('atr_pct'), + 'bb_upper': analysis.get('bb_upper'), + 'bb_middle': analysis.get('bb_middle'), + 'bb_lower': analysis.get('bb_lower'), + 'bb_width': analysis.get('bb_width'), + 'bb_distance_to_lower': analysis.get('bb_distance_to_lower'), + 'bb_distance_to_upper': analysis.get('bb_distance_to_upper'), + 'volume': analysis.get('volume'), + 'volume_avg': analysis.get('volume_avg'), + 'volume_ratio': analysis.get('volume_ratio') or analysis.get('volumeSpike'), + 'volume_spike': analysis.get('volume_spike'), + } + logger.debug(f"🔍 DEBUG {symbol}: indicators_1m construit depuis analysis directement") # 🔥 DEBUG: Compter les valeurs non-null indicators_1m_non_null = len([v for v in indicators_1m.values() if v is not None]) @@ -1014,33 +1066,69 @@ async def scan_pair_for_setup(symbol: str): if not indicators_5m: logger.info(f"🔧 Construction indicators_5m depuis analysis pour {symbol}") - indicators_5m = { - 'rsi': analysis.get('rsi_5m'), - 'rsi_prev': analysis.get('rsi_prev_5m'), - 'macd': analysis.get('macd_5m'), - 'macd_signal': analysis.get('macd_signal_5m'), - 'macd_hist': analysis.get('macd_hist_5m'), - 'macd_hist_prev': analysis.get('macd_hist_prev_5m'), - 'adx': analysis.get('adx_5m'), - 'di_plus': analysis.get('di_plus_5m'), - 'di_minus': analysis.get('di_minus_5m'), - 'di_gap': analysis.get('di_gap_5m'), - 'ema9': analysis.get('ema9_5m'), - 'ema21': analysis.get('ema21_5m'), - 'ema_diff_pct': analysis.get('ema_diff_pct_5m'), - 'atr': analysis.get('atr5m') or analysis.get('atr_5m'), - 'atr_pct': analysis.get('atr_pct_5m'), - 'bb_upper': analysis.get('bb_upper_5m'), - 'bb_middle': analysis.get('bb_middle_5m'), - 'bb_lower': analysis.get('bb_lower_5m'), - 'bb_width': analysis.get('bb_width_5m'), - 'bb_distance_to_lower': analysis.get('bb_distance_to_lower_5m'), - 'bb_distance_to_upper': analysis.get('bb_distance_to_upper_5m'), - 'volume': analysis.get('volume_5m'), - 'volume_avg': analysis.get('volume_avg_5m'), - 'volume_ratio': analysis.get('volume_ratio_5m'), - 'volume_spike': analysis.get('volume_spike_5m'), - } + + # 🔥 PRIORITÉ 1: Vérifier si analysis contient analysis_5m (retourné par analyze_pair quand aucun setup n'est trouvé) + analysis_5m = analysis.get('analysis_5m', {}) + if isinstance(analysis_5m, dict) and analysis_5m: + # Extraire les indicateurs depuis analysis_5m + indicators_5m = { + 'rsi': analysis_5m.get('rsi'), + 'rsi_prev': analysis_5m.get('rsi_prev'), + 'macd': analysis_5m.get('macd'), + 'macd_signal': analysis_5m.get('macd_signal'), + 'macd_hist': analysis_5m.get('macd_hist'), + 'macd_hist_prev': analysis_5m.get('macd_hist_prev'), + 'adx': analysis_5m.get('adx'), + 'di_plus': analysis_5m.get('di_plus'), + 'di_minus': analysis_5m.get('di_minus'), + 'di_gap': analysis_5m.get('di_gap'), + 'ema9': analysis_5m.get('ema9'), + 'ema21': analysis_5m.get('ema21'), + 'ema_diff_pct': analysis_5m.get('ema_diff_pct'), + 'atr': analysis_5m.get('atr'), + 'atr_pct': analysis_5m.get('atr_pct'), + 'bb_upper': analysis_5m.get('bb_upper'), + 'bb_middle': analysis_5m.get('bb_middle'), + 'bb_lower': analysis_5m.get('bb_lower'), + 'bb_width': analysis_5m.get('bb_width'), + 'bb_distance_to_lower': analysis_5m.get('bb_distance_to_lower'), + 'bb_distance_to_upper': analysis_5m.get('bb_distance_to_upper'), + 'volume': analysis_5m.get('volume'), + 'volume_avg': analysis_5m.get('volume_avg'), + 'volume_ratio': analysis_5m.get('volume_ratio') or analysis_5m.get('volumeSpike'), + 'volume_spike': analysis_5m.get('volume_spike'), + } + logger.debug(f"🔍 DEBUG {symbol}: indicators_5m construit depuis analysis_5m") + else: + # 🔥 PRIORITÉ 2: Chercher directement dans analysis (pour les setups valides) + indicators_5m = { + 'rsi': analysis.get('rsi_5m'), + 'rsi_prev': analysis.get('rsi_prev_5m'), + 'macd': analysis.get('macd_5m'), + 'macd_signal': analysis.get('macd_signal_5m'), + 'macd_hist': analysis.get('macd_hist_5m'), + 'macd_hist_prev': analysis.get('macd_hist_prev_5m'), + 'adx': analysis.get('adx_5m'), + 'di_plus': analysis.get('di_plus_5m'), + 'di_minus': analysis.get('di_minus_5m'), + 'di_gap': analysis.get('di_gap_5m'), + 'ema9': analysis.get('ema9_5m'), + 'ema21': analysis.get('ema21_5m'), + 'ema_diff_pct': analysis.get('ema_diff_pct_5m'), + 'atr': analysis.get('atr5m') or analysis.get('atr_5m'), + 'atr_pct': analysis.get('atr_pct_5m'), + 'bb_upper': analysis.get('bb_upper_5m'), + 'bb_middle': analysis.get('bb_middle_5m'), + 'bb_lower': analysis.get('bb_lower_5m'), + 'bb_width': analysis.get('bb_width_5m'), + 'bb_distance_to_lower': analysis.get('bb_distance_to_lower_5m'), + 'bb_distance_to_upper': analysis.get('bb_distance_to_upper_5m'), + 'volume': analysis.get('volume_5m'), + 'volume_avg': analysis.get('volume_avg_5m'), + 'volume_ratio': analysis.get('volume_ratio_5m'), + 'volume_spike': analysis.get('volume_spike_5m'), + } + logger.debug(f"🔍 DEBUG {symbol}: indicators_5m construit depuis analysis directement") # 🔥 DEBUG: Compter les valeurs non-null indicators_5m_non_null = len([v for v in indicators_5m.values() if v is not None]) @@ -1051,6 +1139,319 @@ async def scan_pair_for_setup(symbol: str): analysis['indicators_5m'] = indicators_5m logger.info(f"✅ Indicateurs ajoutés à analysis pour {symbol}: indicators_1m keys: {len(indicators_1m)}, indicators_5m keys: {len(indicators_5m)}") + # 🔥 Simple Logger: Logger ultra-simple sans batch pour debugging + try: + if _simple_logger and hasattr(_simple_logger, 'enabled') and _simple_logger.enabled: + logger.info(f"🔍 DEBUG Simple Logger pour {symbol}: enabled={_simple_logger.enabled}") + + # Récupérer le prix depuis analysis ou price_provider + scan_price = None + if analysis and isinstance(analysis, dict): + scan_price = analysis.get('price') + + # Si le prix n'est pas dans analysis, essayer de le récupérer depuis price_provider + if scan_price is None and price_provider: + try: + price_result = await price_provider.get_price(symbol) + # Extraire la valeur numérique si c'est un dict + if isinstance(price_result, dict): + scan_price = price_result.get('price') or price_result.get('lastPrice') or price_result.get('close') + else: + scan_price = price_result + except Exception as price_error: + logger.debug(f"⚠️ Impossible de récupérer le prix pour {symbol}: {price_error}") + + # Extraire la valeur numérique si scan_price est un dict + if isinstance(scan_price, dict): + scan_price = scan_price.get('price') or scan_price.get('lastPrice') or scan_price.get('close') or scan_price.get('value') + + # Vérifier que scan_price est un nombre + if scan_price is not None and not isinstance(scan_price, (int, float)): + try: + scan_price = float(scan_price) + except (ValueError, TypeError): + logger.warning(f"⚠️ Prix invalide pour {symbol}: {scan_price} (type: {type(scan_price)})") + scan_price = None + + # Construire indicators_1m avec fallbacks + indicators_1m = {} + score_total = None + + if analysis: + # Priorité 1: indicators_1m depuis analysis + indicators_1m = analysis.get('indicators_1m', {}) or {} + + # Récupérer score_total avec fallbacks + # Priorité 1: score_total directement + score_total = analysis.get('score_total') + # Priorité 2: totalScore (nom utilisé dans analyzer.py) + if score_total is None: + score_total = analysis.get('totalScore') + # Priorité 3: score (nom alternatif) + if score_total is None: + score_total = analysis.get('score') + + # 🔥 AMÉLIORATION: Récupérer analysis_1m et analysis_5m une seule fois + analysis_1m = analysis.get('analysis_1m', {}) + analysis_5m = analysis.get('analysis_5m', {}) + + # Fallback 1: Essayer depuis analysis_1m pour score_total + if score_total is None and isinstance(analysis_1m, dict) and analysis_1m: + score_total = analysis_1m.get('score_total') or analysis_1m.get('totalScore') or analysis_1m.get('score') + # Si toujours None, essayer long_score ou short_score (utilisés dans analyzer.py pour les analyses rejetées) + if score_total is None: + # Prendre le maximum entre long_score et short_score, ou le premier non-None + long_score = analysis_1m.get('long_score') + short_score = analysis_1m.get('short_score') + if long_score is not None or short_score is not None: + score_total = max(long_score or 0, short_score or 0) if (long_score is not None and short_score is not None) else (long_score or short_score) + # Fallback 2: Essayer depuis analysis_5m pour score_total + if score_total is None and isinstance(analysis_5m, dict) and analysis_5m: + score_total = analysis_5m.get('score_total') or analysis_5m.get('totalScore') or analysis_5m.get('score') + # Si toujours None, essayer long_score ou short_score + if score_total is None: + long_score = analysis_5m.get('long_score') + short_score = analysis_5m.get('short_score') + if long_score is not None or short_score is not None: + score_total = max(long_score or 0, short_score or 0) if (long_score is not None and short_score is not None) else (long_score or short_score) + + # 🔥 AMÉLIORATION: Compléter indicators_1m avec les valeurs de analysis_1m si elles sont None + if isinstance(analysis_1m, dict) and analysis_1m: + # Log de debug pour voir ce qui est dans analysis_1m + rsi_in_analysis_1m = analysis_1m.get('rsi') + logger.debug(f"🔍 DEBUG {symbol}: analysis_1m contient rsi={rsi_in_analysis_1m}, keys: {list(analysis_1m.keys())[:15]}") + + # Liste des indicateurs clés à vérifier + indicator_keys = ['rsi', 'rsi_prev', 'macd', 'macd_signal', 'macd_hist', 'macd_hist_prev', + 'adx', 'di_plus', 'di_minus', 'di_gap', 'ema9', 'ema21', 'ema_diff_pct', + 'atr', 'atr_pct', 'bb_upper', 'bb_middle', 'bb_lower', 'bb_width', + 'bb_distance_to_lower', 'bb_distance_to_upper', 'volume', 'volume_avg', + 'volume_ratio', 'volume_spike'] + + rsi_found_count = 0 + for key in indicator_keys: + # Si l'indicateur n'existe pas dans indicators_1m ou est None, essayer de le récupérer depuis analysis_1m + if key not in indicators_1m or indicators_1m.get(key) is None: + value = analysis_1m.get(key) + if value is not None: + indicators_1m[key] = value + if key == 'rsi': + rsi_found_count += 1 + logger.info(f"✅ DEBUG {symbol}: RSI récupéré depuis analysis_1m: {value}") + + if rsi_found_count == 0 and rsi_in_analysis_1m is None: + logger.debug(f"⚠️ DEBUG {symbol}: RSI est None dans analysis_1m. analysis_1m contient 'reason': {analysis_1m.get('reason', 'N/A')[:50] if analysis_1m.get('reason') else 'N/A'}") + + # Priorité 3: Essayer depuis analysis directement (champs de haut niveau) si RSI toujours manquant + if not indicators_1m.get('rsi'): + if 'rsi' in analysis and analysis.get('rsi') is not None: + indicators_1m['rsi'] = analysis.get('rsi') + logger.debug(f"🔍 DEBUG {symbol}: RSI récupéré depuis analysis (rsi): {indicators_1m.get('rsi')}") + elif 'rsi_1m' in analysis and analysis.get('rsi_1m') is not None: + indicators_1m['rsi'] = analysis.get('rsi_1m') + logger.debug(f"🔍 DEBUG {symbol}: RSI récupéré depuis analysis (rsi_1m): {indicators_1m.get('rsi')}") + + # Log de debug si RSI toujours manquant + if not indicators_1m.get('rsi'): + logger.debug(f"⚠️ DEBUG {symbol}: RSI non trouvé. analysis keys: {list(analysis.keys())[:10] if analysis else 'None'}, " + f"indicators_1m keys: {list(indicators_1m.keys()) if indicators_1m else 'None'}, " + f"analysis_1m type: {type(analysis.get('analysis_1m'))}, " + f"analysis_1m rsi: {analysis_1m.get('rsi') if isinstance(analysis_1m, dict) else 'N/A'}") + + logger.info(f"📝 Tentative log_scan_simple pour {symbol} (prix: {scan_price}, RSI: {indicators_1m.get('rsi', 'N/A')}, Score: {score_total or 'N/A'})") + # Construire scan_data avec tous les fallbacks possibles + scan_data_dict = { + 'market_data': {'price': scan_price}, + 'indicators_1m': indicators_1m, + 'scores': {'score_total': score_total}, + 'is_opportunity': bool(analysis and 'direction' in analysis and ('entry' in analysis or 'price' in analysis)) if analysis else False + } + # Ajouter analysis_1m et analysis_5m si disponibles (pour les fallbacks dans SimplePGLogger) + if analysis and isinstance(analysis, dict): + if 'analysis_1m' in analysis: + scan_data_dict['analysis_1m'] = analysis.get('analysis_1m') + if 'analysis_5m' in analysis: + scan_data_dict['analysis_5m'] = analysis.get('analysis_5m') + # Ajouter aussi totalScore directement si disponible + if 'totalScore' in analysis: + scan_data_dict['totalScore'] = analysis.get('totalScore') + # Ajouter long_score et short_score si disponibles (pour les fallbacks dans SimplePGLogger) + if 'long_score' in analysis: + scan_data_dict['long_score'] = analysis.get('long_score') + if 'short_score' in analysis: + scan_data_dict['short_score'] = analysis.get('short_score') + result = _simple_logger.log_scan_simple(symbol, scan_data_dict) + logger.info(f"📝 Résultat log_scan_simple pour {symbol}: {result}") + else: + logger.warning(f"⚠️ Simple Logger désactivé pour {symbol}") + except Exception as e: + logger.error(f"❌ Erreur Simple Logger pour {symbol}: {e}") + import traceback + logger.debug(f"Traceback: {traceback.format_exc()}") + + # 🔥 PHASE 1: Logger le scan dans PostgreSQL si activé (comme dans scanner_loop.py) + try: + from core.callbacks.scanner_loop import get_pg_datalogger + pg_datalogger = get_pg_datalogger() + + if pg_datalogger and pg_datalogger.enabled: + # Calculer durée du scan + scan_duration_ms = int((time.time() - scan_start_time) * 1000) + + # Récupérer les données du scan de scalabilité depuis top_pairs + scalability_data = {} + # app_state est défini globalement dans main.py + if app_state and app_state.get('top_pairs'): + for pair in app_state.get('top_pairs', []): + if pair.get('symbol') == symbol: + scalability_data = { + 'recent_volume': pair.get('recentVolume'), + 'vol5': pair.get('vol5'), + 'vol15': pair.get('vol15'), + 'scalability_score': pair.get('score'), + } + break + + # Préparer les données du scan pour PostgreSQL + # 🔥 FIX: Récupérer le prix avec fallbacks (comme pour SimplePGLogger) + scan_price = None + if analysis and isinstance(analysis, dict): + scan_price = analysis.get('price') + + # Si le prix n'est pas dans analysis, essayer de le récupérer depuis price_provider + if scan_price is None and price_provider: + try: + price_result = await price_provider.get_price(symbol) + # Extraire la valeur numérique si c'est un dict + if isinstance(price_result, dict): + scan_price = price_result.get('price') or price_result.get('lastPrice') or price_result.get('close') + else: + scan_price = price_result + except Exception as price_error: + logger.debug(f"⚠️ Impossible de récupérer le prix pour {symbol}: {price_error}") + + # Extraire la valeur numérique si scan_price est un dict + if isinstance(scan_price, dict): + scan_price = scan_price.get('price') or scan_price.get('lastPrice') or scan_price.get('close') or scan_price.get('value') + + # Vérifier que scan_price est un nombre + if scan_price is not None and not isinstance(scan_price, (int, float)): + try: + scan_price = float(scan_price) + except (ValueError, TypeError): + logger.warning(f"⚠️ Prix invalide pour {symbol}: {scan_price} (type: {type(scan_price)})") + scan_price = None + + scan_data = { + 'scan_duration_ms': scan_duration_ms, + 'market_data': { + 'price': scan_price, + 'spread_pct': analysis.get('spread_pct') if analysis else None, + 'book_depth': analysis.get('book_depth') if analysis else None, + 'balance_score': analysis.get('balance_score') if analysis else None, + 'bid_vol': analysis.get('bid_vol') if analysis else None, + 'ask_vol': analysis.get('ask_vol') if analysis else None, + 'orderbook_imbalance_ratio': analysis.get('orderbook_imbalance_ratio') if analysis else None, + # Paramètres du scan de scalabilité + 'recent_volume': scalability_data.get('recent_volume'), + 'vol5': scalability_data.get('vol5'), + 'vol15': scalability_data.get('vol15'), + 'scalability_score': scalability_data.get('scalability_score'), + }, + # Ajouter aussi au niveau racine pour les fallbacks + 'price': scan_price, # 🔥 FIX: Ajouter le prix au niveau racine pour les fallbacks + 'recent_volume': scalability_data.get('recent_volume'), + 'recentVolume': scalability_data.get('recent_volume'), # Alias + 'vol5': scalability_data.get('vol5'), + 'vol15': scalability_data.get('vol15'), + 'scalability_score': scalability_data.get('scalability_score'), + 'score': scalability_data.get('scalability_score'), # Alias + 'indicators_1m': analysis.get('indicators_1m', {}) if analysis else {}, + 'indicators_5m': analysis.get('indicators_5m', {}) if analysis else {}, + 'filters': analysis.get('filters', {}) if analysis else {}, + 'scores': { + 'score_1m': analysis.get('score_1m') if analysis else None, + 'score_5m': analysis.get('score_5m') if analysis else None, + 'score_total': analysis.get('score_total') if analysis else None, + 'score_long_1m': analysis.get('score_long_1m') if analysis else None, + 'score_short_1m': analysis.get('score_short_1m') if analysis else None, + 'score_long_5m': analysis.get('score_long_5m') if analysis else None, + 'score_short_5m': analysis.get('score_short_5m') if analysis else None, + }, + 'patterns': { + 'pattern_1m': analysis.get('pattern_1m') if analysis else None, + 'pattern_multi_1m': analysis.get('pattern_multi_1m') if analysis else None, + 'pattern_5m': analysis.get('pattern_5m') if analysis else None, + 'pattern_multi_5m': analysis.get('pattern_multi_5m') if analysis else None, + }, + 'use_confluence': use_confluence, + 'confluence_met': analysis.get('confluence_met') if analysis else False, + 'timeframes_aligned': analysis.get('timeframes_aligned') if analysis else False, + 'trend_timeframe': trend_timeframe, + 'trend_direction': trend_data.get('trend') if trend_data else None, # 'trend' pas 'direction' + 'trend_strength': None, # trend_data.get('strength') est une chaîne ('STRONG', 'MODERATE', 'NONE'), pas un FLOAT + 'trend_bonus': trend_data.get('bonus') if trend_data else None, + 'divergence_detected': analysis.get('divergence_detected') if analysis else False, + 'divergence_type': analysis.get('divergence_type') if analysis else None, + 'divergence_bonus': analysis.get('divergence_bonus') if analysis else 0, + 'is_opportunity': bool(analysis and 'direction' in analysis and ('entry' in analysis or 'price' in analysis)), + 'opportunity_direction': analysis.get('direction') if analysis and 'direction' in analysis else None, + 'reject_reason': analysis.get('reason') if analysis and 'reason' in analysis else None, + 'reject_reason_category': analysis.get('reject_category') if analysis else None, + 'params_snapshot': { + 'volume_multiplier': volume_multiplier, + 'use_confluence': use_confluence, + 'trend_timeframe': trend_timeframe, + 'min_score_required': TRADING_CONFIG.get('min_score_required', 7.5), + 'min_conditions': TRADING_CONFIG.get('min_conditions', 6), + 'use_weighted_scoring': TRADING_CONFIG.get('use_weighted_scoring', True), + 'snr_threshold': TRADING_CONFIG.get('snr_threshold', 0.25), + 'breakout_threshold': TRADING_CONFIG.get('breakout_threshold', 0.35), + 'wick_ratio_max': TRADING_CONFIG.get('wick_ratio_max', 2.8), + 'optimal_atr_min_1m': TRADING_CONFIG.get('optimal_atr_min_1m', 0.12), + 'optimal_atr_max_1m': TRADING_CONFIG.get('optimal_atr_max_1m', 0.75), + 'optimal_atr_min_5m': TRADING_CONFIG.get('optimal_atr_min_5m', 0.22), + 'optimal_atr_max_5m': TRADING_CONFIG.get('optimal_atr_max_5m', 1.4), + 'use_breakout': TRADING_CONFIG.get('use_breakout', True), + 'use_snr': TRADING_CONFIG.get('use_snr', True), + 'use_wick': TRADING_CONFIG.get('use_wick', True), + 'use_divergence': TRADING_CONFIG.get('use_divergence', True), + } + } + + # Logger le scan (mode batch par défaut) + logger.info(f"📝 Appel log_scan() pour {symbol} (main.py)") + scan_id = pg_datalogger.log_scan(symbol, scan_data, use_batch=True) + logger.info(f"✅ log_scan() terminé pour {symbol} (scan_id={scan_id})") + + # Si c'est une opportunité, logger aussi dans opportunities + if scan_data['is_opportunity'] and analysis: + opportunity_data = { + 'status': 'PENDING', + 'direction': analysis.get('direction'), + 'setup_score': analysis.get('score_total') or analysis.get('totalScore'), + 'conditions_matched': analysis.get('condition_types', []) or analysis.get('signals', []), + 'entry_price': analysis.get('entry') or analysis.get('price'), + 'tp_price': analysis.get('tp'), + 'sl_price': analysis.get('sl'), + 'tp_sl_mode': TRADING_CONFIG.get('tp_sl_mode', 'FIXE'), + 'size_usdt': None, + 'risk_usdt': None, + 'reward_risk_ratio': None, + } + logger.info(f"📝 Appel log_opportunity() pour {symbol} (main.py)") + pg_datalogger.log_opportunity( + scan_id or 0, # 0 = temporaire, sera mis à jour lors du flush + symbol, + opportunity_data, + use_batch=True + ) + logger.info(f"✅ log_opportunity() terminé pour {symbol}") + except Exception as e: + logger.error(f"❌ Erreur logging PostgreSQL pour {symbol} (main.py): {e}") + import traceback + logger.debug(f"Traceback: {traceback.format_exc()}") + # 🔥 FIX: Envoyer événement SocketIO pour mettre à jour le compteur de validation # Un setup valide = validé (true), pas de setup = non validé (false) is_valid = False @@ -1414,71 +1815,102 @@ def init_instances(): logger.info("✅ PostgreSQL DataLogger initialisé") # Injecter dans scanner_loop from core.callbacks.scanner_loop import set_pg_datalogger - if set_pg_datalogger: - set_pg_datalogger(pg_datalogger) - - # 🔥 PHASE 3: Créer tâche périodique pour logging contexte marché - async def log_market_context_periodic(): - """Tâche périodique pour logger le contexte marché""" - while True: - try: - await asyncio.sleep(300) # Toutes les 5 minutes - if pg_datalogger and pg_datalogger.enabled: - try: - # Récupérer prix BTC/ETH - from api.price_provider import get_price_provider - price_provider = get_price_provider() - - context_data = { - 'btc_price': None, - 'eth_price': None, - 'global_metrics': {}, - 'session_stats': {}, - 'market_trend': None, - 'market_volatility': None, - 'fear_greed_index': None - } - - if price_provider: - try: - btc_price = await price_provider.get_price('BTCUSDT') - eth_price = await price_provider.get_price('ETHUSDT') - context_data['btc_price'] = btc_price - context_data['eth_price'] = eth_price - except Exception: - pass - - # Récupérer stats session si disponibles - if hasattr(app_state, 'get'): - context_data['session_stats'] = { - 'total_trades': app_state.get('total_trades', 0), - 'win_rate': app_state.get('win_rate', 0), - 'total_pnl': app_state.get('total_pnl', 0) - } - - pg_datalogger.log_market_context(context_data) - except Exception as e: - logger.debug(f"Erreur logging contexte marché périodique: {e}") - except asyncio.CancelledError: - break - except Exception as e: - logger.warning(f"Erreur tâche contexte marché: {e}") - await asyncio.sleep(60) # Attendre avant de réessayer - - # Démarrer la tâche périodique - asyncio.create_task(log_market_context_periodic()) - logger.info("✅ Tâche périodique contexte marché démarrée") - else: - logger.warning("⚠️ PostgreSQL DataLogger désactivé (connexion échouée)") - pg_datalogger = None - else: - logger.debug("ℹ️ PostgreSQL DataLogger désactivé (POSTGRES_ENABLED=false)") + set_pg_datalogger(pg_datalogger) + logger.info("✅ PostgreSQL DataLogger injecté dans scanner_loop") except ImportError as e: logger.debug(f"ℹ️ PostgreSQL DataLogger non disponible: {e}") except Exception as e: logger.warning(f"⚠️ Erreur initialisation PostgreSQL DataLogger: {e}") pg_datalogger = None + # 🔥 PHASE 3: Créer tâche périodique pour logging contexte marché + if pg_datalogger and pg_datalogger.enabled: + async def log_market_context_periodic(): + """Tâche périodique pour logger le contexte marché""" + while True: + try: + await asyncio.sleep(300) # Toutes les 5 minutes + if pg_datalogger and pg_datalogger.enabled: + try: + # Récupérer prix BTC/ETH + from api.price_provider import get_price_provider + price_provider = get_price_provider() + + context_data = { + 'btc_price': None, + 'eth_price': None, + 'global_metrics': {}, + 'session_stats': {}, + 'market_trend': None, + 'market_volatility': None, + 'fear_greed_index': None + } + + if price_provider: + try: + btc_price = await price_provider.get_price('BTCUSDT') + eth_price = await price_provider.get_price('ETHUSDT') + context_data['btc_price'] = btc_price + context_data['eth_price'] = eth_price + except Exception: + pass + + # Récupérer stats session si disponibles + if hasattr(app_state, 'get'): + context_data['session_stats'] = { + 'total_trades': app_state.get('total_trades', 0), + 'win_rate': app_state.get('win_rate', 0), + 'total_pnl': app_state.get('total_pnl', 0) + } + + pg_datalogger.log_market_context(context_data) + except Exception as e: + logger.debug(f"Erreur logging contexte marché périodique: {e}") + except asyncio.CancelledError: + break + except Exception as e: + logger.warning(f"Erreur tâche contexte marché: {e}") + await asyncio.sleep(60) # Attendre avant de réessayer + + # Démarrer la tâche périodique + asyncio.create_task(log_market_context_periodic()) + logger.info("✅ Tâche périodique contexte marché démarrée") + + # 🔥 PHASE 3: Tâche périodique pour flush forcé des buffers + async def flush_buffers_periodic(): + """Tâche périodique pour forcer le flush des buffers toutes les 30 secondes""" + while True: + try: + await asyncio.sleep(30) # Toutes les 30 secondes + if pg_datalogger and pg_datalogger.enabled: + try: + # Forcer le flush même si buffer pas plein + pg_datalogger._flush_buffers(force=True) + except Exception as e: + logger.debug(f"Erreur flush périodique: {e}") + except asyncio.CancelledError: + break + except Exception as e: + logger.warning(f"Erreur tâche flush périodique: {e}") + await asyncio.sleep(30) # Attendre avant de réessayer + + # Démarrer la tâche de flush périodique + asyncio.create_task(flush_buffers_periodic()) + logger.info("✅ Tâche périodique flush buffers démarrée (toutes les 30s)") + + # 🔥 Simple Logger: Initialiser SimplePGLogger pour debugging + global _simple_logger + try: + from core.simple_pg_logger import SimplePGLogger + _simple_logger = SimplePGLogger() + if _simple_logger.enabled: + logger.info("✅ SimplePGLogger connecté") + else: + logger.warning("⚠️ SimplePGLogger désactivé") + except Exception as e: + logger.error(f"❌ Erreur initialisation SimplePGLogger: {e}") + _simple_logger = None + # 🔥 NOUVEAU: Injecter Position Manager, Notification Manager et instance port # Récupérer port instance pour multi-instances port = int(sys.argv[1]) if len(sys.argv) > 1 else 5000 @@ -4011,6 +4443,287 @@ async def export_trades_csv( headers={"Content-Disposition": f"attachment; filename={filename}"} ) + +@app.get("/api/datalogger/export/excel") +async def export_datalogger_excel( + start_date: Optional[str] = None, + end_date: Optional[str] = None +): + """ + 🔥 Export des données du datalogger en Excel (.xlsx) + + Args: + start_date: Date début (YYYY-MM-DD) - optionnel + end_date: Date fin (YYYY-MM-DD) - optionnel + + Returns: + Fichier Excel (.xlsx) avec plusieurs onglets (scans, opportunities, trades) + """ + try: + from core.callbacks.scanner_loop import get_pg_datalogger + pg_datalogger = get_pg_datalogger() + + if not pg_datalogger or not pg_datalogger.enabled: + return JSONResponse( + {"error": "PostgreSQL DataLogger non disponible"}, + status_code=503 + ) + + # Vérifier si openpyxl est installé + try: + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment + from openpyxl.utils import get_column_letter + except ImportError: + return JSONResponse( + {"error": "openpyxl non installé. Installez-le avec: pip install openpyxl"}, + status_code=500 + ) + + conn = pg_datalogger._get_connection() + if not conn: + return JSONResponse( + {"error": "Impossible de se connecter à PostgreSQL"}, + status_code=503 + ) + + try: + from psycopg2.extras import RealDictCursor + cursor = conn.cursor(cursor_factory=RealDictCursor) + + # Créer un workbook Excel + wb = Workbook() + wb.remove(wb.active) # Supprimer la feuille par défaut + + # ===== ONGLET 1: SCANS ===== + ws_scans = wb.create_sheet("Scans") + query_scans = """ + SELECT + timestamp, symbol, price, scan_duration_ms, + rsi_1m, rsi_5m, score_total, + is_opportunity, opportunity_direction, reject_reason, + trend_direction, trend_strength + FROM scan_logs + WHERE 1=1 + """ + params = [] + if start_date: + query_scans += " AND timestamp >= %s" + params.append(f"{start_date} 00:00:00") + if end_date: + query_scans += " AND timestamp <= %s" + params.append(f"{end_date} 23:59:59") + query_scans += " ORDER BY timestamp DESC LIMIT 10000" + + cursor.execute(query_scans, params) + scans = cursor.fetchall() + + if scans: + # En-têtes + headers = list(scans[0].keys()) + ws_scans.append(headers) + + # Style en-têtes + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + for cell in ws_scans[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center") + + # Données + for row in scans: + ws_scans.append([row.get(h) for h in headers]) + + # Ajuster largeur colonnes + for col in range(1, len(headers) + 1): + ws_scans.column_dimensions[get_column_letter(col)].width = 15 + + # ===== ONGLET 2: OPPORTUNITIES ===== + ws_opps = wb.create_sheet("Opportunities") + query_opps = """ + SELECT + timestamp, symbol, direction, setup_score, + entry_price, tp_price, sl_price, + conditions_matched, confirmed_by + FROM opportunities + WHERE 1=1 + """ + params_opps = [] + if start_date: + query_opps += " AND timestamp >= %s" + params_opps.append(f"{start_date} 00:00:00") + if end_date: + query_opps += " AND timestamp <= %s" + params_opps.append(f"{end_date} 23:59:59") + query_opps += " ORDER BY timestamp DESC LIMIT 10000" + + cursor.execute(query_opps, params_opps) + opportunities = cursor.fetchall() + + if opportunities: + headers = list(opportunities[0].keys()) + ws_opps.append(headers) + + for cell in ws_opps[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center") + + for row in opportunities: + ws_opps.append([row.get(h) for h in headers]) + + for col in range(1, len(headers) + 1): + ws_opps.column_dimensions[get_column_letter(col)].width = 15 + + # ===== ONGLET 3: TRADES ===== + ws_trades = wb.create_sheet("Trades") + query_trades = """ + SELECT + timestamp_entry, timestamp_exit, symbol, direction, + entry_price, exit_price, size_usdt, + gross_pnl_usdt, net_pnl_usdt, net_pnl_pct, + exit_reason, duration_seconds, win + FROM trades + WHERE 1=1 + """ + params_trades = [] + if start_date: + query_trades += " AND timestamp_entry >= %s" + params_trades.append(f"{start_date} 00:00:00") + if end_date: + query_trades += " AND timestamp_entry <= %s" + params_trades.append(f"{end_date} 23:59:59") + query_trades += " ORDER BY timestamp_entry DESC LIMIT 10000" + + cursor.execute(query_trades, params_trades) + trades = cursor.fetchall() + + if trades: + headers = list(trades[0].keys()) + ws_trades.append(headers) + + for cell in ws_trades[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center") + + for row in trades: + ws_trades.append([row.get(h) for h in headers]) + + for col in range(1, len(headers) + 1): + ws_trades.column_dimensions[get_column_letter(col)].width = 15 + + cursor.close() + pg_datalogger._return_connection(conn) + + # Sauvegarder dans un buffer + from io import BytesIO + output = BytesIO() + wb.save(output) + output.seek(0) + + filename = f"datalogger_export_{start_date}_{end_date}.xlsx" if (start_date and end_date) else f"datalogger_export_all_{datetime.now().strftime('%Y%m%d')}.xlsx" + + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + except Exception as e: + pg_datalogger._return_connection(conn) + logger.error(f"❌ Erreur export Excel: {e}", exc_info=True) + return JSONResponse( + {"error": f"Erreur export Excel: {str(e)}"}, + status_code=500 + ) + + except Exception as e: + logger.error(f"❌ Erreur export Excel: {e}", exc_info=True) + return JSONResponse( + {"error": f"Erreur export Excel: {str(e)}"}, + status_code=500 + ) + + +@app.delete("/api/datalogger/reset") +async def reset_datalogger_db(): + """ + 🔥 Reset complet de la base de données PostgreSQL du datalogger + + ATTENTION: Cette opération supprime TOUTES les données (scans, opportunities, trades, etc.) + + Returns: + Message de confirmation + """ + try: + from core.callbacks.scanner_loop import get_pg_datalogger + pg_datalogger = get_pg_datalogger() + + if not pg_datalogger or not pg_datalogger.enabled: + return JSONResponse( + {"error": "PostgreSQL DataLogger non disponible"}, + status_code=503 + ) + + conn = pg_datalogger._get_connection() + if not conn: + return JSONResponse( + {"error": "Impossible de se connecter à PostgreSQL"}, + status_code=503 + ) + + try: + cursor = conn.cursor() + + # Supprimer toutes les données (dans l'ordre pour respecter les contraintes FK) + tables = [ + 'trades', + 'opportunities', + 'scan_logs', + 'scan_errors', + 'market_context', + 'config_snapshots', + 'trading_sessions' + ] + + deleted_counts = {} + for table in tables: + cursor.execute(f"DELETE FROM {table}") + deleted_counts[table] = cursor.rowcount + + conn.commit() + cursor.close() + pg_datalogger._return_connection(conn) + + total_deleted = sum(deleted_counts.values()) + logger.warning(f"🗑️ Base de données PostgreSQL resetée: {total_deleted} enregistrements supprimés") + + return JSONResponse({ + "success": True, + "message": f"Base de données resetée avec succès", + "deleted": deleted_counts, + "total_deleted": total_deleted + }) + + except Exception as e: + conn.rollback() + pg_datalogger._return_connection(conn) + logger.error(f"❌ Erreur reset DB: {e}", exc_info=True) + return JSONResponse( + {"error": f"Erreur reset DB: {str(e)}"}, + status_code=500 + ) + + except Exception as e: + logger.error(f"❌ Erreur reset DB: {e}", exc_info=True) + return JSONResponse( + {"error": f"Erreur reset DB: {str(e)}"}, + status_code=500 + ) + + if __name__ == '__main__': import uvicorn @@ -4034,6 +4747,8 @@ async def export_trades_csv( logger.info(f"❌ API Setups rejetés → http://localhost:{port}/api/setups/rejected") logger.info(f"✅ API Setups validés → http://localhost:{port}/api/setups/validated") logger.info(f"📥 API Export (CSV/JSON) → http://localhost:{port}/api/export?format=csv") + logger.info(f"📊 API Export Excel (XLSX) → http://localhost:{port}/api/datalogger/export/excel") + logger.info(f"🗑️ API Reset DB → DELETE http://localhost:{port}/api/datalogger/reset") logger.info(f"🔄 API Backtest → POST http://localhost:{port}/api/backtest") logger.info(f"🤖 API ML Optimize → POST http://localhost:{port}/api/optimize") logger.info("=" * 70) diff --git a/tests/test_postgresql_datalogger.py b/tests/test_postgresql_datalogger.py index 7d0ea600..b5614f94 100644 --- a/tests/test_postgresql_datalogger.py +++ b/tests/test_postgresql_datalogger.py @@ -182,9 +182,10 @@ def test_log_scan_error(self, mock_pool_class, datalogger_config, mock_pool, moc error_message='Connection timeout', error_details={'stack': 'traceback...'} ) - + assert error_id is not None - cursor.execute.assert_called_once() + # 🔥 FIX: 2 appels attendus (1 pour session, 1 pour scan_error) + assert cursor.execute.call_count == 2 @patch('core.postgresql_datalogger.PSYCOPG2_AVAILABLE', True) @patch('core.postgresql_datalogger.ThreadedConnectionPool') @@ -206,9 +207,10 @@ def test_log_market_context(self, mock_pool_class, datalogger_config, mock_pool, } context_id = logger.log_market_context(context_data) - + assert context_id is not None - cursor.execute.assert_called_once() + # 🔥 FIX: 2 appels attendus (1 pour session, 1 pour market_context) + assert cursor.execute.call_count == 2 @patch('core.postgresql_datalogger.PSYCOPG2_AVAILABLE', True) @patch('core.postgresql_datalogger.ThreadedConnectionPool') @@ -250,9 +252,10 @@ def test_log_trade(self, mock_pool_class, datalogger_config, mock_pool, mock_pos } trade_id = logger.log_trade(trade_data) - + assert trade_id is not None - cursor.execute.assert_called_once() + # 🔥 FIX: 2 appels attendus (1 pour session, 1 pour trade) + assert cursor.execute.call_count == 2 @patch('core.postgresql_datalogger.PSYCOPG2_AVAILABLE', True) @patch('core.postgresql_datalogger.ThreadedConnectionPool')