Détection de Wedge — Algorithme, Dédup, Confidence
Le wedge est un pattern de convergence de deux trendlines (highs et lows) qui annonce souvent un breakout imminent. Ce document explique comment TNT le détecte côté Python, comment la couche PHP maintient un seul enregistrement par wedge malgré l'évolution continue des trendlines, et ce que représente vraiment le score confidence.
Algorithme de détection
Le détecteur vit dans python/src/tnt_python/indicators/market_structure/market_structure_v2.py et s'appuie sur les pivots (swing highs/lows) déjà extraits par le SwingDetector. Il travaille en deux étapes séquentielles.
Étape 1 — _detect_wedge() : identifier un wedge en formation
- Prend les 4 derniers pivots highs et les 4 derniers pivots lows.
- Fitte deux droites par régression linéaire (
np.polyfit) : une sur les highs, une sur les lows. - Teste la convergence des pentes :
- Descending wedge (bullish breakout) :
h_slope < 0 && l_slope < 0 && h_slope < l_slope— les highs descendent plus vite que les lows, les deux lignes convergent vers le haut. - Ascending wedge (bearish breakout) :
h_slope > 0 && l_slope > 0 && l_slope > h_slope— les lows montent plus vite que les highs, les deux lignes convergent vers le bas.
- Descending wedge (bullish breakout) :
- Si aucune convergence valide, retourne
None.
if h_slope < 0 and l_slope < 0 and h_slope < l_slope:
strength = min(abs(h_slope - l_slope) / (abs(h_slope) + 1e-9), 1.0)
return WedgePattern(
wedge_type="descending_wedge",
bias="bullish_breakout",
strength=round(float(strength), 3),
...
)
Étape 2 — _detect_wedge_breakout() : chercher la bougie de breakout
- Part du bar
wedge.end_bar + 1(premier bar possible pour un breakout). - Scanne jusqu'à la bougie courante, cappé à
max_bars_since_breakout = 15bars pour éviter de surfacer des breakouts anciens. - Première bougie dont la close dépasse la trendline projetée (
slope × bar_idx + intercept) avec une tolérance de ±0.3% (0.997pour bull,1.003pour bear) → c'est la bougie de breakout.
Important : la détection utilise la trendline projetée dans le futur, pas la confirmation d'un nouveau pivot. Résultat : le breakout fire dès la close de la bougie qui casse, sans attendre.
Payload envoyé à Laravel
python/src/tnt_python/cli/compute_context.py sérialise le wedge en dict :
patterns.append({
"type": "wedge",
"direction": direction, # "bull" | "bear"
"status": "confirmed" if breakout_type else "active",
"confidence": round(w.strength, 4),
"breakout_price": brk_price,
"invalidation_price": inv_price,
"start_time": start_time, # 1er pivot — clé de dédup
"end_time": end_time,
"payload_json": {
"wedge_type": w.wedge_type,
"bias": w.bias,
"breakout_type": breakout_type,
"breakout_time": brk_time, # timestamp exact de la bougie breakout
"upper_line": [...],
"lower_line": [...],
},
})
Mécanisme de dédup
Un wedge évolue en continu entre chaque run du cron context : les trendlines s'ajustent à chaque nouveau pivot, le end_bar avance, les prix bougent légèrement. Il faut donc une clé stable pour reconnaître « c'est le même wedge que la dernière fois ».
Le code utilise une stratégie dédiée aux wedges, différente de celle des autres patterns. Elle vit dans app/Repositories/MarketContextRepository.php:490 :
private function findWedgeByStartTime(
int $coinId, string $timeframe, string $direction, string $startTime
): ?MarketPattern {
return MarketPattern::where('coin_id', $coinId)
->where('timeframe', $timeframe)
->where('pattern_type', 'wedge')
->where('direction', $direction)
->where('start_time', $startTime)
->whereIn('status', ['active', 'confirmed'])
->first();
}
Clé de dédup = (coin_id, timeframe, pattern_type='wedge', direction, start_time)
Le start_time est le timestamp absolu UTC du premier pivot du wedge. C'est stable parce que :
- Les pivots sont des points historiques déjà confirmés — ils ne bougent pas rétroactivement.
- Le
start_timeest un timestamp absolu, pas unbar_index(qui lui se décale à chaque run avec la rolling window).
Donc tant qu'un wedge garde son premier pivot, c'est le même pattern en DB : on UPDATE les trendlines, le end_bar, le confidence_latest, last_seen_at, et on bump seen_count++.
Quand est-ce qu'on crée un nouveau wedge ?
- Le premier pivot sort du scope (ex : un pivot plus ancien apparaît et devient le nouveau début, ou le pattern se re-forme sur une base différente).
- Changement de direction (bull → bear ou inverse).
Pourquoi tu peux voir plusieurs wedges actifs sur un même TF
Plusieurs wedges se sont formés successivement, chacun avec un start_time distinct → chacun est une entrée séparée. La dédup ne les fusionne pas (ce ne sont pas « le même wedge » dans le sens où leur point de départ diffère).
Nuance : les autres patterns utilisent d'autres clés
- Zones S&D (
demand_zone,supply_zone) → dédup par overlap de prix ≥ 70%. - BOS, swings → dédup par
pattern_key= hash de(type, direction, prix arrondis à ±0.5%). - Balance → singleton par
(coin, timeframe)— une seule balance active à la fois.
Le wedge est le seul à utiliser start_time comme clé.
Confidence : ce que ça mesure vraiment
Pour un wedge, confidence = wedge.strength calculé dans _detect_wedge() :
# Descending (bullish)
strength = min(abs(h_slope - l_slope) / (abs(h_slope) + 1e-9), 1.0)
# Ascending (bearish)
strength = min(abs(l_slope - h_slope) / (abs(l_slope) + 1e-9), 1.0)
C'est le ratio de différence entre les pentes des deux trendlines, normalisé par la pente dominante :
- Proche de 1.0 → les deux droites convergent fortement (fort différentiel de pente). Wedge « serré », compression forte, breakout imminent plus probable.
- Proche de 0.0 → les deux droites sont quasi parallèles. C'est plus un channel qu'un wedge : convergence très lente, breakout lointain ou incertain.
Deux colonnes en DB : confidence vs confidence_latest
Le repository (MarketContextRepository.php:310-311) écrit deux valeurs à chaque update :
'confidence_latest' => $confidence, // valeur du dernier run
'confidence' => max((float) ($existing->confidence ?? 0), $confidence), // max historique
confidence→ le max historique vu sur ce wedge. Reflète la meilleure forme que ce wedge a jamais eue.confidence_latest→ la valeur du dernier run. Reflète l'état instantané.
Un wedge peut avoir confidence = 0.85 mais confidence_latest = 0.42 : il était bien formé à un moment, puis s'est aplati. C'est volontaire — la colonne confidence sert au ranking/sélection, confidence_latest au monitoring de dégradation.
Attention au filtre côté chart
Le filtre appliqué sur la page /market-context/{SYMBOL} (MarketContextController@show:165) utilise la colonne confidence :
$chartPatterns = $patterns
->filter(fn ($p) => !($p->pattern_type === 'wedge' && ($p->confidence ?? 1.0) < 0.60))
Donc le filtre regarde le max historique. Un wedge qui s'est dégradé reste visible sur le chart tant qu'il a atteint ≥ 0.60 au moins une fois dans sa vie. Si on voulait filtrer sur l'état courant, il faudrait utiliser confidence_latest.
Diagnostic rapide
Pour inspecter un wedge en DB :
ddev artisan tinker
> App\Models\MarketPattern::find($id)
->only(['status','confirmed_at','confidence','confidence_latest',
'breakout_price','payload_json','start_time','last_seen_at','seen_count'])
status = 'confirmed'+breakout_price != null→ breakout détecté.payload_json->breakout_time→ timestamp exact de la bougie de breakout (plus précis queconfirmed_atqui est l'instant d'écriture Laravel).seen_countélevé +last_seen_atrécent → wedge toujours tracké par Python à chaque run.
Fichiers de référence
python/src/tnt_python/indicators/market_structure/market_structure_v2.py— détecteur Pythonpython/src/tnt_python/cli/compute_context.py— sérialisation du payloadapp/Repositories/MarketContextRepository.php— upsert + dédupapp/Http/Controllers/MarketContextController.php— filtres d'affichagedatabase/migrations/2026_05_08_000001_create_market_patterns_table.php— schéma DB