Files
AH-Oil-Trader/core/patterns.py
T
Axel HocksandClaude Opus 4.8 ae6bbb2f35 Chartmuster grafisch: SVG-Skizze in Karte + Trigger/Ziel-Linien im PWA-Chart
- patterns.py: nur AKTIVE, kursnahe Muster (abgearbeitete/Ziel erreicht raus,
  _MAX_DIST_ATR=5.0 Distanz-Filter)
- get_bars liefert bei M30 die Muster (patterns.snapshot) fürs Chart-Overlay
- app.js: _patternSvg-Skizze je Muster-Karte + 2 stärkste Muster als
  Trigger-/Ziel-Preislinien im M30-Chart; Gitternetz dezenter (#10151c)
- style.css: .pt-svg; index.html v=116->117; CLAUDE.md aktualisiert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 19:13:47 +02:00

247 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
core/patterns.py — Visuelle Chartmuster-Erkennung (ANZEIGE, kein Signal)
========================================================================
Erkennt mechanisch definierte Chartmuster aus den M30-Swing-Pivots und liefert
sie fürs Dashboard: Typ, Richtung, Nackenlinie/Trigger, Measured-Move-Ziel,
Status (bildet sich / bestätigt / ungültig) und eine Qualitäts-Note.
Erkannte Muster (v1):
• Doppeltop / Doppelboden (2 gleiche Extrema + Nackenlinie)
• Kopf-Schulter / invers (SKS) (3 Extrema, Mitte am höchsten/tiefsten)
• Tasse+Henkel / invers (heuristisch: abgerundetes Extrem + Henkel)
⚠ REINE ANZEIGE — KEIN Trade-Trigger, KEIN Verdict-Gewicht. Signal-Einfluss ERST
nach Backtest (`backtest_patterns.py`, noch zu bauen): dieselbe Klasse (Doppeltop,
Struktur) ist im Projekt schon 2× als SIGNAL verworfen (`backtest_doubletop.py`,
`backtest_structure.py`) → hohe Beweislast. Das Modul zeigt nur den Ist-Zustand +
das (mechanische) Measured-Move-Ziel, damit man sieht, was ein Kommentator meint.
Thread-sicher wie structure.py: refresh_market(sym) holt M30-Bars unter mt5_lock
(~30 s gedrosselt), snapshot() liefert den letzten Stand ohne MT5-Call.
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.logger import get_logger
log = get_logger("patterns")
_TF = mt5.TIMEFRAME_M30
_N_BARS = 240
_PIVOT_K = 3
_REFRESH_S = 30.0
_MIN_HEIGHT_ATR = 1.2 # Muster-Höhe muss ≥ so viel ATR sein (sonst Rauschen)
_TOL_ATR = 0.7 # zwei "gleiche" Extrema dürfen so weit auseinander (×ATR)
_NECK_TOL_ATR = 0.9 # Nackenlinie "flach": zwei Tröge/Hochs so weit (×ATR)
_MAX_LOOKBACK = 9 # nur die letzten N Swings betrachten (aktuelle Muster)
_MAX_DIST_ATR = 5.0 # Trigger max. so weit vom AKTUELLEN Kurs (sonst nicht mehr
# aktionabel — alte, weit weg gelaufene Muster ausblenden)
def _atr(highs, lows, closes, p=14):
trs = []
for i in range(1, len(closes)):
trs.append(max(highs[i]-lows[i], abs(highs[i]-closes[i-1]), abs(lows[i]-closes[i-1])))
return (sum(trs[-p:]) / min(len(trs), p)) if trs else None
def _pivots(highs, lows, k):
"""Alternierende Swing-Punkte → [(index, price, 'H'/'L')] (wie structure.py)."""
n = len(highs); raw = []
for i in range(k, n - k):
if highs[i] == max(highs[i-k:i+k+1]) and highs[i] > highs[i-1] and highs[i] >= highs[i+1]:
raw.append((i, highs[i], "H"))
elif lows[i] == min(lows[i-k:i+k+1]) and lows[i] < lows[i-1] and lows[i] <= lows[i+1]:
raw.append((i, lows[i], "L"))
out = []
for p in raw:
if out and out[-1][2] == p[2]:
if (p[2] == "H" and p[1] > out[-1][1]) or (p[2] == "L" and p[1] < out[-1][1]):
out[-1] = p
else:
out.append(p)
return out
def _sim(a, b, atr):
"""Ähnlichkeit zweier Preise (1 = identisch, 0 = ≥ _TOL_ATR entfernt)."""
return max(0.0, 1.0 - abs(a - b) / (_TOL_ATR * atr)) if atr else 0.0
def _status(dir_, trigger, cur, extreme):
"""bestätigt (Trigger gebrochen) / ungültig (Extrem überschritten) / bildet sich."""
if dir_ == "bear":
if cur < trigger: return "confirmed"
if cur > extreme: return "invalidated"
else:
if cur > trigger: return "confirmed"
if cur < extreme: return "invalidated"
return "forming"
def _detect(piv, cur, atr, last_idx):
"""Sucht Muster in den letzten Swings. → Liste Muster-dicts."""
out = []
P = piv[-_MAX_LOOKBACK:]
if len(P) < 3:
return out
pr = [p[1] for p in P]; kd = [p[2] for p in P]; ix = [p[0] for p in P]
def add(typ, name, dir_, quality, trigger, extreme, note):
h = abs(extreme - trigger)
if h < _MIN_HEIGHT_ATR * atr: # zu flach = Rauschen
return
target = trigger - h if dir_ == "bear" else trigger + h
out.append({
"type": typ, "name": name, "dir": dir_,
"quality": round(quality, 2), "trigger": round(trigger, 3),
"target": round(target, 3), "height_atr": round(h / atr, 1),
"status": _status(dir_, trigger, cur, extreme),
"bars_ago": last_idx - ix[-1], "note": note,
})
# über alle 3er/5er-Fenster am aktuellen Rand
for s in range(len(P) - 2):
w = list(zip(kd[s:], pr[s:], ix[s:]))
# ── Doppeltop: H, L, H (zwei ~gleiche Hochs, Trog dazwischen) ──
if len(w) >= 3 and w[0][0] == "H" and w[1][0] == "L" and w[2][0] == "H":
Ha, Lm, Hb = w[0][1], w[1][1], w[2][1]
q = _sim(Ha, Hb, atr)
if q > 0.35 and Lm < min(Ha, Hb):
add("double_top", "Doppeltop", "bear", q, Lm, max(Ha, Hb),
f"zwei Hochs {Ha:.2f}/{Hb:.2f}, Nackenlinie {Lm:.2f}")
# ── Doppelboden: L, H, L ──
if len(w) >= 3 and w[0][0] == "L" and w[1][0] == "H" and w[2][0] == "L":
La, Hm, Lb = w[0][1], w[1][1], w[2][1]
q = _sim(La, Lb, atr)
if q > 0.35 and Hm > max(La, Lb):
add("double_bottom", "Doppelboden", "bull", q, Hm, min(La, Lb),
f"zwei Tiefs {La:.2f}/{Lb:.2f}, Nackenlinie {Hm:.2f}")
# ── Kopf-Schulter (SKS): H, L, H, L, H (Kopf = mittleres Hoch am höchsten) ──
if len(w) >= 5 and [x[0] for x in w[:5]] == ["H", "L", "H", "L", "H"]:
ls, t1, head, t2, rs = (w[i][1] for i in range(5))
if head > ls and head > rs:
neck = (t1 + t2) / 2.0
q = _sim(ls, rs, atr) * _sim(t1, t2, atr)
if q > 0.25 and abs(t1 - t2) < _NECK_TOL_ATR * atr:
add("hs", "Kopf-Schulter", "bear", q, neck, head,
f"Kopf {head:.2f}, Schultern {ls:.2f}/{rs:.2f}, Nacken {neck:.2f}")
# ── Inverse SKS: L, H, L, H, L ──
if len(w) >= 5 and [x[0] for x in w[:5]] == ["L", "H", "L", "H", "L"]:
ls, t1, head, t2, rs = (w[i][1] for i in range(5))
if head < ls and head < rs:
neck = (t1 + t2) / 2.0
q = _sim(ls, rs, atr) * _sim(t1, t2, atr)
if q > 0.25 and abs(t1 - t2) < _NECK_TOL_ATR * atr:
add("inv_hs", "Inverse SKS", "bull", q, neck, head,
f"Kopf {head:.2f}, Schultern {ls:.2f}/{rs:.2f}, Nacken {neck:.2f}")
# ── (Inverse) Tasse+Henkel (heuristisch): Rand-Extrema ~gleich, Boden/Top
# dazwischen, breit; Henkel = letzter kleiner Gegen-Swing. ──
if len(w) >= 3:
span = w[2][2] - w[0][2] # Bars zwischen den Rand-Punkten
if w[0][0] == "L" and w[1][0] == "H" and w[2][0] == "L" and span >= 10:
rim_l, top, rim_r = w[0][1], w[1][1], w[2][1] # inverse Tasse (abger. Top)
q = _sim(rim_l, rim_r, atr) * 0.9
if q > 0.4 and top > max(rim_l, rim_r):
add("inv_cup", "Inverse Tasse+Henkel", "bear", q, min(rim_l, rim_r), top,
f"abgerundetes Top {top:.2f}, Rand ~{rim_r:.2f} (Henkel-Unterkante)")
if w[0][0] == "H" and w[1][0] == "L" and w[2][0] == "H" and span >= 10:
rim_l, bot, rim_r = w[0][1], w[1][1], w[2][1] # Tasse (abger. Boden)
q = _sim(rim_l, rim_r, atr) * 0.9
if q > 0.4 and bot < min(rim_l, rim_r):
add("cup", "Tasse+Henkel", "bull", q, max(rim_l, rim_r), bot,
f"abgerundeter Boden {bot:.2f}, Rand ~{rim_r:.2f} (Henkel-Oberkante)")
# ── Dreiecke (Fortsetzung): letzte 2 Hochs + 2 Tiefs, Steigungen prüfen ──
highs = [(x[2], x[1]) for x in zip(kd, pr, ix) if x[0] == "H"][-2:]
lows = [(x[2], x[1]) for x in zip(kd, pr, ix) if x[0] == "L"][-2:]
if len(highs) == 2 and len(lows) == 2:
(ih1, h1p), (ih2, h2p) = highs
(il1, l1p), (il2, l2p) = lows
flat_h = abs(h2p - h1p) <= _NECK_TOL_ATR * atr
flat_l = abs(l2p - l1p) <= _NECK_TOL_ATR * atr
rising_l = l2p > l1p + 0.4 * atr
falling_h = h2p < h1p - 0.4 * atr
wide = abs(max(h1p, h2p) - min(l1p, l2p))
if wide >= _MIN_HEIGHT_ATR * atr:
if flat_h and rising_l: # aufsteigendes Dreieck (bullisch)
res_ = max(h1p, h2p)
add("tri_asc", "Aufsteigendes Dreieck", "bull", 0.6, res_, min(l1p, l2p),
f"flacher Widerstand {res_:.2f}, steigende Tiefs")
elif flat_l and falling_h: # absteigendes Dreieck (bärisch)
sup_ = min(l1p, l2p)
add("tri_desc", "Absteigendes Dreieck", "bear", 0.6, sup_, max(h1p, h2p),
f"flacher Support {sup_:.2f}, fallende Hochs")
elif falling_h and rising_l: # symmetrisch → Ausbruch offen (nur Anzeige)
out.append({
"type": "tri_sym", "name": "Symmetrisches Dreieck", "dir": "neutral",
"quality": 0.5, "trigger": round((max(h1p, h2p) + min(l1p, l2p)) / 2, 3),
"target": None, "height_atr": round(wide / atr, 1),
"status": "forming", "bars_ago": last_idx - ix[-1],
"note": f"konvergierend (Hochs fallen, Tiefs steigen) — Ausbruchsrichtung offen",
})
# Duplikate (gleicher Typ+Trigger) zusammenfassen, beste Qualität behalten
best = {}
for m in out:
key = (m["type"], round(m["trigger"], 2))
if key not in best or m["quality"] > best[key]["quality"]:
best[key] = m
def played_out(m):
"""Ziel bereits erreicht → Muster abgearbeitet, nicht mehr zeigen."""
t = m.get("target")
if t is None:
return False
return (cur <= t) if m["dir"] == "bear" else (cur >= t)
# raus: ungültig · abgearbeitet (Ziel erreicht) · zu weit vom Kurs (nicht mehr
# aktionabel — nur AKTIVE, kursnahe Muster zeigen).
res = [m for m in best.values()
if m["status"] != "invalidated"
and not played_out(m)
and abs(m["trigger"] - cur) <= _MAX_DIST_ATR * atr]
rank = {"confirmed": 2, "forming": 1}
res.sort(key=lambda m: (rank.get(m["status"], 0), m["quality"], -m["bars_ago"]), reverse=True)
return res
class PatternDetector:
def __init__(self):
self._snap = {"patterns": [], "atr": None, "ts": 0.0}
self._last_refresh = 0.0
self._lock = threading.Lock()
def refresh_market(self, sym: str):
now = time.time()
if now - self._last_refresh < _REFRESH_S:
return
self._last_refresh = now
try:
with mt5_lock(timeout=2) as got:
if not got:
return
bars = mt5.copy_rates_from_pos(sym, _TF, 0, _N_BARS)
if bars is None or len(bars) < 60:
return
H = [float(b["high"]) for b in bars]
L = [float(b["low"]) for b in bars]
C = [float(b["close"]) for b in bars]
atr = _atr(H, L, C)
if not atr or atr <= 0:
return
piv = _pivots(H, L, _PIVOT_K)
patterns = _detect(piv, C[-1], atr, len(H) - 1)
with self._lock:
self._snap = {"patterns": patterns, "atr": round(atr, 4),
"cur": round(C[-1], 3), "ts": now}
except Exception as e:
log.warning(f"patterns.refresh_market: {e}")
def snapshot(self) -> dict:
with self._lock:
return dict(self._snap)