Initial commit: Oil Trading Bot (MT5, WTI)
Headless FastAPI-Backend (server.py + core/engine.py) mit Mobile-PWA (web/), Strategie-/Backtest-Suite und Doku. Secrets, DB, Logs und Laufzeit-State sind via .gitignore ausgeschlossen; Config-Vorlage: oil_widget_config.ini.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
core/structure.py — Marktstruktur-Erkennung (ANZEIGE, kein Signal)
|
||||
==================================================================
|
||||
Erkennt aus den M30-Bars die klassische Price-Action-Struktur und liefert sie
|
||||
als Kontext fürs Dashboard:
|
||||
• Swing-Folge HH / HL / LH / LL (Pivot-Hochs/-Tiefs, jeweils vs. Vorgänger)
|
||||
• letzter BOS (Break of Structure: Richtung + gebrochenes Level)
|
||||
• Regressionskanal (Richtung + Position des Kurses im Kanal 0..1)
|
||||
• Gesamt-Struktur up / down / range
|
||||
|
||||
REINE ANZEIGE — wie TF-Ampel/Squeeze/Bounce: KEIN Trade-Trigger, KEIN Verdict-
|
||||
Gewicht. Die handelbaren Varianten sind separat gemessen & verworfen:
|
||||
BOS-Entry ≈ Momentum-Continuation (`backtest_momentum.py`, regime-abhängig),
|
||||
Kanal-/Zonen-Bounce ≈ P(break)-Level-Bounce (6× belegt: Münzwurf am Extrem).
|
||||
Deshalb malt dieses Modul KEINE Richtung/Prognose — es beschreibt nur den Ist-Zustand.
|
||||
|
||||
Thread-sicher: refresh_market(sym) holt die 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("structure")
|
||||
|
||||
_TF = mt5.TIMEFRAME_M30 # Struktur auf M30 (klare Swings, wie der Referenz-Chart)
|
||||
_N_BARS = 220
|
||||
_PIVOT_K = 3 # Swing-Pivot-Fenster (k Bars je Seite)
|
||||
_REG_N = 60 # Regressionsfenster für den Kanal (~30 h auf M30)
|
||||
_SLOPE_DEAD = 0.015 # |Steigung/Bar| < dead×ATR → Kanal "flat"
|
||||
_MAX_SWINGS = 6 # so viele letzte Swings ausgeben
|
||||
_REFRESH_S = 30.0 # Drossel (Struktur ändert sich langsam, spart Lock-Zeit)
|
||||
|
||||
|
||||
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 → Liste (index, price, kind) mit kind 'H'/'L'.
|
||||
Swing-High bei i: höchster Bar im Fenster [i-k .. i+k] und lokales Maximum."""
|
||||
n = len(highs)
|
||||
raw = []
|
||||
for i in range(k, n - k):
|
||||
win_hi = max(highs[i-k:i+k+1]); win_lo = min(lows[i-k:i+k+1])
|
||||
if highs[i] == win_hi and highs[i] > highs[i-1] and highs[i] >= highs[i+1]:
|
||||
raw.append((i, highs[i], "H"))
|
||||
elif lows[i] == win_lo and lows[i] < lows[i-1] and lows[i] <= lows[i+1]:
|
||||
raw.append((i, lows[i], "L"))
|
||||
# Alternierung erzwingen: zwei gleiche Typen in Folge → den extremeren behalten
|
||||
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 _classify(pivots):
|
||||
"""Swing-Folge als HH/HL/LH/LL (vs. jeweils vorheriges High bzw. Low)."""
|
||||
labels = []
|
||||
last_h = last_l = None
|
||||
for idx, price, kind in pivots:
|
||||
if kind == "H":
|
||||
lab = ("HH" if (last_h is not None and price > last_h)
|
||||
else "LH" if last_h is not None else "H")
|
||||
last_h = price
|
||||
else:
|
||||
lab = ("HL" if (last_l is not None and price > last_l)
|
||||
else "LL" if last_l is not None else "L")
|
||||
last_l = price
|
||||
labels.append({"type": lab, "price": round(price, 3), "idx": idx})
|
||||
return labels
|
||||
|
||||
|
||||
def _trend_state(labels):
|
||||
recent = [l["type"] for l in labels[-4:]]
|
||||
ups = sum(1 for t in recent if t in ("HH", "HL"))
|
||||
dns = sum(1 for t in recent if t in ("LH", "LL"))
|
||||
if ups >= 3 and ups > dns:
|
||||
return "up"
|
||||
if dns >= 3 and dns > ups:
|
||||
return "down"
|
||||
return "range"
|
||||
|
||||
|
||||
def _last_bos(labels, n_bars):
|
||||
"""Letzter Break of Structure: jüngstes HH (bullisch, Vorlauf-Hoch gebrochen)
|
||||
bzw. LL (bärisch). Level = das gebrochene vorige Extrem; bars_ago aus dem Index."""
|
||||
prev_h = prev_l = None
|
||||
bos = None
|
||||
for l in labels:
|
||||
if l["type"] in ("HH", "LH"):
|
||||
if l["type"] == "HH" and prev_h is not None:
|
||||
bos = {"dir": "up", "level": prev_h, "idx": l["idx"]}
|
||||
prev_h = l["price"]
|
||||
else:
|
||||
if l["type"] == "LL" and prev_l is not None:
|
||||
bos = {"dir": "down", "level": prev_l, "idx": l["idx"]}
|
||||
prev_l = l["price"]
|
||||
if bos:
|
||||
bos["bars_ago"] = max(0, (n_bars - 1) - bos.pop("idx"))
|
||||
bos["level"] = round(bos["level"], 3)
|
||||
return bos
|
||||
|
||||
|
||||
def _channel(closes, atr):
|
||||
N = min(_REG_N, len(closes))
|
||||
if N < 5:
|
||||
return None
|
||||
ys = closes[-N:]
|
||||
mx = (N - 1) / 2.0
|
||||
my = sum(ys) / N
|
||||
sxx = sum((x - mx) ** 2 for x in range(N))
|
||||
sxy = sum((x - mx) * (ys[x] - my) for x in range(N))
|
||||
slope = sxy / sxx if sxx else 0.0
|
||||
intercept = my - slope * mx
|
||||
resid = [ys[x] - (slope * x + intercept) for x in range(N)]
|
||||
up_off, lo_off = max(resid), min(resid)
|
||||
last_x = N - 1
|
||||
mid = slope * last_x + intercept
|
||||
upper, lower = mid + up_off, mid + lo_off
|
||||
width = upper - lower
|
||||
pos = (ys[-1] - lower) / width if width > 0 else 0.5
|
||||
if atr and abs(slope) < _SLOPE_DEAD * atr:
|
||||
d = "flat"
|
||||
else:
|
||||
d = "up" if slope > 0 else "down"
|
||||
return {"dir": d, "pos": round(max(0.0, min(1.0, pos)), 2),
|
||||
"upper": round(upper, 3), "lower": round(lower, 3), "mid": round(mid, 3),
|
||||
"slope_atr": round(slope / atr, 3) if atr else None}
|
||||
|
||||
|
||||
def _channel_anchors(closes, times, atr):
|
||||
"""Kanal als 2 Ankerpunkte je Linie (Fensterstart + letzter abgeschl. Bar) mit
|
||||
BROKER-Zeiten — für die MQL5-Bridge (OBJ_TREND, nach rechts verlängert).
|
||||
closes/times = abgeschlossene Bars (gleich lang). Gibt {t1,t2,dir,upper,mid,lower}."""
|
||||
N = min(_REG_N, len(closes))
|
||||
if N < 5 or len(times) < N:
|
||||
return None
|
||||
seg = closes[-N:]; tt = times[-N:]
|
||||
mx = (N - 1) / 2.0; my = sum(seg) / N
|
||||
sxx = sum((x - mx) ** 2 for x in range(N))
|
||||
sxy = sum((x - mx) * (seg[x] - my) for x in range(N))
|
||||
b = sxy / sxx if sxx else 0.0
|
||||
a = my - b * mx
|
||||
resid = [seg[x] - (a + b * x) for x in range(N)]
|
||||
up_off, lo_off = max(resid), min(resid)
|
||||
m1 = a; m2 = a + b * (N - 1)
|
||||
d = "flat" if (atr and abs(b) < _SLOPE_DEAD * atr) else ("up" if b > 0 else "down")
|
||||
return {"t1": int(tt[0]), "t2": int(tt[-1]), "dir": d,
|
||||
"upper": [round(m1 + up_off, 3), round(m2 + up_off, 3)],
|
||||
"mid": [round(m1, 3), round(m2, 3)],
|
||||
"lower": [round(m1 + lo_off, 3), round(m2 + lo_off, 3)]}
|
||||
|
||||
|
||||
def channel_series(closes, atr, k):
|
||||
"""Regressionskanal (mid/upper/lower) als Arrays der LETZTEN k Bars fürs
|
||||
Chart-Overlay — Regression über die letzten _REG_N ABGESCHLOSSENEN Bars,
|
||||
linear über alle k Bars extrapoliert (volle Chart-Breite). closes = alle
|
||||
Closes (letzter = offener Bar). Gibt {dir, mid[], upper[], lower[]} zurück
|
||||
(jeweils Länge k, deckungsgleich mit den zurückgelieferten Bars) oder None."""
|
||||
if k < 2 or len(closes) < 6:
|
||||
return None
|
||||
cc = closes[:-1] # nur abgeschlossene Bars (wie die Struktur)
|
||||
N = min(_REG_N, len(cc))
|
||||
if N < 5:
|
||||
return None
|
||||
seg = cc[-N:]
|
||||
mx = (N - 1) / 2.0
|
||||
my = sum(seg) / N
|
||||
sxx = sum((x - mx) ** 2 for x in range(N))
|
||||
sxy = sum((x - mx) * (seg[x] - my) for x in range(N))
|
||||
b = sxy / sxx if sxx else 0.0
|
||||
a = my - b * mx # Preis bei x=0 (Fensterstart)
|
||||
resid = [seg[x] - (a + b * x) for x in range(N)]
|
||||
up_off, lo_off = max(resid), min(resid)
|
||||
x0 = len(cc) - N # cc-Index von x=0
|
||||
base = len(closes) - k # closes-Index des ersten Ausgabe-Bars
|
||||
mid, up, lo = [], [], []
|
||||
for j in range(k):
|
||||
x = (base + j) - x0 # x relativ zum Fensterstart (extrapoliert)
|
||||
m = a + b * x
|
||||
mid.append(round(m, 3)); up.append(round(m + up_off, 3)); lo.append(round(m + lo_off, 3))
|
||||
d = "flat" if (atr and abs(b) < _SLOPE_DEAD * atr) else ("up" if b > 0 else "down")
|
||||
return {"dir": d, "mid": mid, "upper": up, "lower": lo}
|
||||
|
||||
|
||||
class MarketStructure:
|
||||
def __init__(self):
|
||||
self._snap: dict = {"trend": None, "swings": [], "last_swing": None,
|
||||
"bos": None, "channel": None, "tf": "M30", "error": None}
|
||||
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
|
||||
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) < _REG_N + 5:
|
||||
return
|
||||
# letzte (offene) Kerze weglassen → nur abgeschlossene Struktur
|
||||
highs = [float(b["high"]) for b in bars[:-1]]
|
||||
lows = [float(b["low"]) for b in bars[:-1]]
|
||||
closes = [float(b["close"]) for b in bars[:-1]]
|
||||
times = [int(b["time"]) for b in bars[:-1]] # Broker-Zeit (MQL5-Anker)
|
||||
n = len(closes)
|
||||
atr = _atr(highs, lows, closes)
|
||||
piv = _pivots(highs, lows, _PIVOT_K)
|
||||
labels = _classify(piv)
|
||||
snap = {
|
||||
"trend": _trend_state(labels) if labels else "range",
|
||||
"swings": [{"type": l["type"], "price": l["price"]}
|
||||
for l in labels[-_MAX_SWINGS:]],
|
||||
"last_swing": labels[-1]["type"] if labels else None,
|
||||
"bos": _last_bos(labels, n),
|
||||
"channel": _channel(closes, atr),
|
||||
"channel_line": _channel_anchors(closes, times, atr),
|
||||
"tf": "M30",
|
||||
"error": None,
|
||||
}
|
||||
with self._lock:
|
||||
self._snap = snap
|
||||
self._last_refresh = now
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self._snap["error"] = str(e)[:120]
|
||||
log.warning(f"MarketStructure.refresh: {e}")
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
return dict(self._snap)
|
||||
Reference in New Issue
Block a user