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:
Axel Hocks
2026-07-24 08:29:23 +02:00
co-authored by Claude Opus 4.8
commit 75d28827e8
104 changed files with 21059 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
"""
core/analysis/__init__.py
Re-exportiert alle öffentlichen Symbole für Rückwärtskompatibilität.
Alle bestehenden Imports wie `from core.analysis import calc_recommendation`
funktionieren unverändert weiter.
"""
from core.analysis.indicators import (
_ema,
calc_trend_angle,
FIB_RATIOS,
calc_rsi,
calc_atr,
calc_volume_ratio,
calc_fib_levels,
calc_fib_distance,
detect_regime,
calc_sr_distance,
calc_sma,
calc_vwap,
)
from core.analysis.ict import (
calc_bos,
calc_fvg,
calc_asia_levels,
calc_liquidity_sweep,
calc_order_block,
calc_ichimoku,
calc_coc,
)
from core.analysis.news import (
NEWS_BULLISH_KW,
NEWS_BEARISH_KW,
calc_news_sentiment,
)
from core.analysis.m15 import M15Analyzer, SRDetector
__all__ = [
"_ema", "calc_trend_angle", "FIB_RATIOS",
"calc_rsi", "calc_atr", "calc_volume_ratio",
"calc_fib_levels", "calc_fib_distance",
"detect_regime", "calc_sr_distance",
"calc_sma", "calc_vwap",
"calc_bos", "calc_fvg", "calc_asia_levels",
"calc_liquidity_sweep", "calc_order_block", "calc_ichimoku", "calc_coc",
"NEWS_BULLISH_KW", "NEWS_BEARISH_KW", "calc_news_sentiment",
"M15Analyzer", "SRDetector",
]
+348
View File
@@ -0,0 +1,348 @@
"""
core/analysis/ict.py — ICT / SMC Konzepte
BOS, FVG, Asia Levels, Liquidity Sweep, Order Block, Ichimoku
"""
from __future__ import annotations
def calc_bos(highs: list, lows: list, closes: list,
lookback: int = 30, pivot_win: int = 3) -> dict:
"""
Break of Structure (ICT/SMC).
Rückgabe: {'bos': 'bullish'|'bearish'|None, 'bos_level': float|None, 'bars_ago': int|None}
"""
n = len(closes)
if n < lookback + pivot_win + 3:
return {"bos": None, "bos_level": None, "bars_ago": None}
w = pivot_win
search_end = n - 1
last_swing_high = last_swing_low = None
for i in range(search_end - w - 1, max(w, search_end - lookback - 1), -1):
lo = max(0, i - w); hi_r = min(n - 1, i + w)
if last_swing_high is None and highs[i] == max(highs[lo : hi_r + 1]):
last_swing_high = highs[i]
if last_swing_low is None and lows[i] == min(lows[lo : hi_r + 1]):
last_swing_low = lows[i]
if last_swing_high is not None and last_swing_low is not None:
break
if last_swing_high is None or last_swing_low is None:
return {"bos": None, "bos_level": None, "bars_ago": None}
for ago in range(1, 6):
if n - ago - 1 < 1:
break
c_now = closes[n - ago]
c_prev = closes[n - ago - 1]
if c_now < last_swing_low <= c_prev:
return {"bos": "bearish", "bos_level": last_swing_low, "bars_ago": ago}
if c_now > last_swing_high >= c_prev:
return {"bos": "bullish", "bos_level": last_swing_high, "bars_ago": ago}
return {"bos": None, "bos_level": None, "bars_ago": None}
def calc_fvg(highs: list, lows: list, closes: list, lookback: int = 20) -> dict:
"""
Fair Value Gap / Imbalance (ICT-Definition).
Rückgabe: {'type': 'bullish'|'bearish'|None, 'top', 'bottom', 'mid', 'filled_pct', 'bars_ago'}
"""
n = len(closes)
if n < 4:
return {"type": None}
cur = closes[-1]
for i in range(n - 3, max(1, n - lookback - 2), -1):
if i + 2 >= n:
continue
h_before = highs[i - 1]; l_before = lows[i - 1]
h_after = highs[i + 1]; l_after = lows[i + 1]
if h_before < l_after:
bottom, top = h_before, l_after
if top <= bottom:
continue
filled_pct = max(0.0, min(100.0, (cur - bottom) / (top - bottom) * 100))
if filled_pct < 100.0:
return {"type": "bullish", "top": top, "bottom": bottom,
"mid": (top + bottom) / 2,
"filled_pct": round(filled_pct, 0), "bars_ago": n - 2 - i}
elif l_before > h_after:
bottom, top = h_after, l_before
if top <= bottom:
continue
filled_pct = max(0.0, min(100.0, (top - cur) / (top - bottom) * 100))
if filled_pct < 100.0:
return {"type": "bearish", "top": top, "bottom": bottom,
"mid": (top + bottom) / 2,
"filled_pct": round(filled_pct, 0), "bars_ago": n - 2 - i}
return {"type": None}
def calc_asia_levels(bars: list) -> dict | None:
"""
Asien-Session Hoch/Tief (00:0008:00 UTC) aus M15-Bars.
Rückgabe: {'high': float, 'low': float, 'n': int} oder None.
"""
if not bars:
return None
import time as _time
from datetime import datetime, timezone as _tz
now_ts = _time.time()
today_utc = datetime.fromtimestamp(now_ts, tz=_tz.utc).replace(
hour=0, minute=0, second=0, microsecond=0)
today_ts = today_utc.timestamp()
asia_end_ts = today_ts + 8 * 3600
asia_bars = [b for b in bars
if today_ts <= int(b["time"]) < asia_end_ts]
if not asia_bars:
return None
return {
"high": max(float(b["high"]) for b in asia_bars),
"low": min(float(b["low"]) for b in asia_bars),
"n": len(asia_bars),
}
def calc_liquidity_sweep(highs: list, lows: list, closes: list, opens: list,
lookback: int = 25, pivot_win: int = 3) -> dict:
"""
Liquidity Sweep (ICT): Wick über Swing-High/-Low, Schluss zurück.
Rückgabe: {'sweep': 'bearish'|'bullish'|None, 'level': float|None, 'bars_ago': int|None}
"""
n = len(closes)
if n < lookback + pivot_win + 3:
return {"sweep": None, "level": None, "bars_ago": None}
w = pivot_win
search_end = n - 1
swing_highs = []
swing_lows = []
for i in range(max(w, search_end - lookback), search_end - w):
lo = max(0, i - w); hi_r = min(n - 1, i + w)
if highs[i] == max(highs[lo : hi_r + 1]):
swing_highs.append(highs[i])
if lows[i] == min(lows[lo : hi_r + 1]):
swing_lows.append(lows[i])
if not swing_highs or not swing_lows:
return {"sweep": None, "level": None, "bars_ago": None}
pivot_high = max(swing_highs)
pivot_low = min(swing_lows)
for ago in range(1, 4):
idx = n - ago - 1
if idx < 1:
break
h = highs[idx]; l = lows[idx]; c = closes[idx]
if h > pivot_high and c < pivot_high:
return {"sweep": "bearish", "level": pivot_high, "bars_ago": ago}
if l < pivot_low and c > pivot_low:
return {"sweep": "bullish", "level": pivot_low, "bars_ago": ago}
return {"sweep": None, "level": None, "bars_ago": None}
def calc_order_block(highs: list, lows: list, closes: list, opens: list,
lookback: int = 40, min_impulse_bars: int = 3,
atr: float | None = None) -> dict:
"""
Order Block (ICT/SMC).
Bullish OB: letzter Bear-Candle vor starkem Aufwärts-Impuls → Support-Zone
Bearish OB: letzter Bull-Candle vor starkem Abwärts-Impuls → Resistance-Zone
Rückgabe: {'type': 'bullish'|'bearish'|None, 'high', 'low', 'mid', 'bars_ago', 'mitigated'}
"""
n = len(closes)
if n < lookback + min_impulse_bars + 2:
return {"type": None}
atr_eff = atr if atr and atr > 0 else 0.5
min_move = 1.5 * atr_eff
for end in range(n - min_impulse_bars - 1, max(1, n - lookback - 1), -1):
if end + min_impulse_bars >= n:
continue
bull_move = closes[end + min_impulse_bars] - closes[end]
bear_move = closes[end] - closes[end + min_impulse_bars]
if bull_move > min_move:
for ob_i in range(end, max(0, end - 6), -1):
if closes[ob_i] < opens[ob_i]:
ob_h = highs[ob_i]; ob_l = lows[ob_i]
mit = any(lows[j] < ob_h and highs[j] > ob_l
for j in range(ob_i + 1, n))
return {"type": "bullish", "high": ob_h, "low": ob_l,
"mid": (ob_h + ob_l) / 2,
"bars_ago": n - 1 - ob_i, "mitigated": mit}
elif bear_move > min_move:
for ob_i in range(end, max(0, end - 6), -1):
if closes[ob_i] > opens[ob_i]:
ob_h = highs[ob_i]; ob_l = lows[ob_i]
mit = any(highs[j] > ob_l and lows[j] < ob_h
for j in range(ob_i + 1, n))
return {"type": "bearish", "high": ob_h, "low": ob_l,
"mid": (ob_h + ob_l) / 2,
"bars_ago": n - 1 - ob_i, "mitigated": mit}
return {"type": None}
def calc_coc(highs: list, lows: list, closes: list,
lookback: int = 50, pivot_win: int = 3) -> dict:
"""
Change of Character (CoC / CHOCH) — ICT/SMC Trendumkehrsignal.
Algorithmus:
1. Finde das jüngste Swing-High UND das jüngste Swing-Low im Lookback.
2. Welches Extrem ist jünger bestimmt den vorherigen Bias:
• SH jünger → Uptrend → suche das letzte Swing-Low VOR dem SH (= Higher Low)
Wenn Close unter dieses HL bricht → bearischer CoC
• SL jünger → Downtrend → suche das letzte Swing-High VOR dem SL (= Lower High)
Wenn Close über dieses LH bricht → bullischer CoC
Unterschied zu BOS:
BOS = Strukturbruch IN Trendrichtung (Fortsetzung)
CoC = Strukturbruch GEGEN den Trend (Umkehrsignal, stärker)
Rückgabe:
coc: 'bearish' | 'bullish' | None
coc_level: gebrochenes Strukturniveau (Higher Low / Lower High)
swing_extreme: letztes Swing-Extrem (SH/SL = der Pivot der den Trend definierte)
bars_ago: Bars seit dem Bruch
"""
n = len(closes)
if n < pivot_win * 2 + 12:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
w = pivot_win
lb = min(lookback, n - w - 2)
def _find_pivot(seq_high: bool, start: int, stop: int) -> tuple[int, float] | None:
for i in range(start, max(w, stop), -1):
lo = max(0, i - w); hi_r = min(n - 1, i + w)
if seq_high and highs[i] == max(highs[lo:hi_r + 1]):
return (i, highs[i])
if not seq_high and lows[i] == min(lows[lo:hi_r + 1]):
return (i, lows[i])
return None
# ── Jüngstes Swing-High und Swing-Low im Lookback ────────────────────────
recent_sh = _find_pivot(True, n - 1 - w, n - lb - 1)
recent_sl = _find_pivot(False, n - 1 - w, n - lb - 1)
if recent_sh is None or recent_sl is None:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
sh_idx, sh_price = recent_sh
sl_idx, sl_price = recent_sl
lb_stop = max(w, n - lb - 1) # ältestes Bar das in Lookback fällt
# ── Bearish CoC: letztes Extrem war ein Swing-High ────────────────────────
if sh_idx > sl_idx:
# Suche den Swing-Low VOR dem SH (= der Higher Low im Uptrend)
# Suchbereich: komplett rückwärts bis Ende des Lookback-Fensters
hl = _find_pivot(False, sh_idx - w - 1, lb_stop)
if hl is None:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
hl_price = hl[1]
for ago in range(1, 10):
if n - ago - 1 < 1:
break
c_now = closes[n - ago]
c_prev = closes[n - ago - 1]
if c_now < hl_price <= c_prev:
return {"coc": "bearish", "coc_level": round(hl_price, 5),
"swing_extreme": round(sh_price, 5), "bars_ago": ago}
# ── Bullish CoC: letztes Extrem war ein Swing-Low ─────────────────────────
elif sl_idx > sh_idx:
# Suche den Swing-High VOR dem SL (= der Lower High im Downtrend)
lh = _find_pivot(True, sl_idx - w - 1, lb_stop)
if lh is None:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
lh_price = lh[1]
for ago in range(1, 10):
if n - ago - 1 < 1:
break
c_now = closes[n - ago]
c_prev = closes[n - ago - 1]
if c_now > lh_price >= c_prev:
return {"coc": "bullish", "coc_level": round(lh_price, 5),
"swing_extreme": round(sl_price, 5), "bars_ago": ago}
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
def calc_ichimoku(highs: list, lows: list, closes: list,
tenkan: int = 9, kijun: int = 26, senkou_b: int = 52) -> dict | None:
"""
Ichimoku Kinko Hyo — Wolken-Analyse (Standard 9/26/52).
ichi_bias: 4=strong_bull, 3=bull, 2=neutral, 1=bear, 0=strong_bear
"""
n = len(closes)
if n < senkou_b + kijun + 1:
return None
def midpoint(h_sl, l_sl):
return (max(h_sl) + min(l_sl)) / 2
tenkan_val = midpoint(highs[-tenkan:], lows[-tenkan:])
kijun_val = midpoint(highs[-kijun:], lows[-kijun:])
off = kijun
if n - off - 1 < senkou_b:
return None
idx = n - off - 1
t_ago = midpoint(highs[idx - tenkan + 1: idx + 1], lows[idx - tenkan + 1: idx + 1])
k_ago = midpoint(highs[idx - kijun + 1: idx + 1], lows[idx - kijun + 1: idx + 1])
a_val = (t_ago + k_ago) / 2
b_val = midpoint(highs[idx - senkou_b + 1: idx + 1], lows[idx - senkou_b + 1: idx + 1])
cloud_top = max(a_val, b_val)
cloud_bot = min(a_val, b_val)
cur = closes[-1]
price_vs_cloud = ("above" if cur > cloud_top else
"below" if cur < cloud_bot else "inside")
tk_signal = "bullish" if tenkan_val >= kijun_val else "bearish"
cloud_color = "green" if a_val >= b_val else "red"
chikou_signal = "neutral"
if n > kijun:
ref = closes[n - 1 - kijun]
chikou_signal = "bullish" if cur > ref else ("bearish" if cur < ref else "neutral")
bull_pts = (
(1 if price_vs_cloud == "above" else 0) +
(1 if tk_signal == "bullish" else 0) +
(1 if chikou_signal == "bullish" else 0) +
(1 if cloud_color == "green" else 0)
)
ichi_bias = {4: "strong_bull", 3: "bull", 1: "bear", 0: "strong_bear"}.get(bull_pts, "neutral")
return {
"tenkan": round(tenkan_val, 3),
"kijun": round(kijun_val, 3),
"senkou_a": round(a_val, 3),
"senkou_b": round(b_val, 3),
"cloud_top": round(cloud_top, 3),
"cloud_bot": round(cloud_bot, 3),
"cloud_color": cloud_color,
"price_vs_cloud": price_vs_cloud,
"tk_signal": tk_signal,
"chikou_signal": chikou_signal,
"ichi_bias": ichi_bias,
"bull_pts": bull_pts,
}
+252
View File
@@ -0,0 +1,252 @@
"""
core/analysis/indicators.py — Mathematische Indikatoren & Helper
"""
from __future__ import annotations
import math
from core.config import ANGLE_LR_BARS
def _ema(values: list, period: int) -> list:
"""Exponentieller gleitender Durchschnitt."""
k = 2.0 / (period + 1)
out = [values[0]]
for v in values[1:]:
out.append(v * k + out[-1] * (1 - k))
return out
def calc_trend_angle(closes: list, n: int = ANGLE_LR_BARS) -> float:
"""
Lineare Regression über die letzten n Schlusskurse.
Liefert 0°–180°: 0° = starker Aufwärtstrend
90° = seitwärts
180° = starker Abwärtstrend
"""
data = closes[-n:] if len(closes) >= n else closes
k = len(data)
if k < 2:
return 90.0
xm = (k - 1) / 2.0
ym = sum(data) / k
num = sum((i - xm) * (v - ym) for i, v in enumerate(data))
den = sum((i - xm) ** 2 for i in range(k))
if den == 0:
return 90.0
slope = num / den
slope_pct = slope / (ym or 1) * 100
angle_rad = math.atan(slope_pct * 18)
angle = 90.0 - math.degrees(angle_rad)
return max(0.0, min(180.0, angle))
FIB_RATIOS = (0.236, 0.382, 0.500, 0.618, 0.786)
def calc_rsi(closes: list, period: int = 14) -> float:
"""Wilder-RSI über die letzten `period` Schlusskurse. 0100."""
if len(closes) < period + 1:
return 50.0
gains, losses = 0.0, 0.0
for i in range(1, period + 1):
diff = closes[i] - closes[i - 1]
if diff >= 0:
gains += diff
else:
losses -= diff
avg_g = gains / period
avg_l = losses / period
for i in range(period + 1, len(closes)):
diff = closes[i] - closes[i - 1]
g = max(diff, 0.0)
l = max(-diff, 0.0)
avg_g = (avg_g * (period - 1) + g) / period
avg_l = (avg_l * (period - 1) + l) / period
if avg_l == 0:
return 100.0
rs = avg_g / avg_l
return 100.0 - 100.0 / (1.0 + rs)
def calc_atr(highs: list, lows: list, closes: list, period: int = 14) -> float | None:
"""Average True Range, Wilder-Smoothing. Liefert None bei zu wenig Daten."""
n = len(highs)
if n < period + 1 or n != len(lows) or n != len(closes):
return None
trs = []
for i in range(1, n):
tr = max(highs[i] - lows[i],
abs(highs[i] - closes[i - 1]),
abs(lows[i] - closes[i - 1]))
trs.append(tr)
if len(trs) < period:
return None
atr = sum(trs[:period]) / period
for tr in trs[period:]:
atr = (atr * (period - 1) + tr) / period
return atr
def calc_volume_ratio(bars: list, lookback: int = 20) -> float:
"""
Verhältnis des letzten Bar-Tick-Volumens zum Ø der vorherigen lookback Bars.
> 1.5 = überdurchschnittlich, < 0.7 = unterdurchschnittlich
"""
vols = []
for b in bars:
try:
vols.append(float(b["tick_volume"] or 0))
except Exception:
vols.append(0.0)
if len(vols) < 3:
return 1.0
window = vols[-(lookback + 1):-1]
avg = sum(window) / len(window) if window else 0.0
return round(vols[-1] / avg, 2) if avg > 0 else 1.0
def calc_fib_levels(highs: list, lows: list, lookback: int = 50) -> dict | None:
"""
Fibonacci-Retracement-Level aus dem größten Swing-High/Low im lookback-Fenster.
"""
n = len(highs)
if n < 10 or n != len(lows):
return None
start = max(0, n - lookback)
win_h = highs[start:]
win_l = lows[start:]
hi_rel = max(range(len(win_h)), key=lambda i: win_h[i])
lo_rel = min(range(len(win_l)), key=lambda i: win_l[i])
swing_high = win_h[hi_rel]
swing_low = win_l[lo_rel]
rng = swing_high - swing_low
if rng <= 0:
return None
up_levels = {round(r * 100, 1): round(swing_high - r * rng, 5) for r in FIB_RATIOS}
down_levels = {round(r * 100, 1): round(swing_low + r * rng, 5) for r in FIB_RATIOS}
hi_idx = start + hi_rel
lo_idx = start + lo_rel
return {
"swing_high": swing_high,
"swing_low": swing_low,
"high_idx": hi_idx,
"low_idx": lo_idx,
"range": rng,
"direction": "up" if hi_idx > lo_idx else "down",
"levels": {"up": up_levels, "down": down_levels},
}
def calc_fib_distance(price: float, fib: dict | None, direction: str = "up") -> dict:
"""Abstand des Preises zum nächsten Schlüssel-Fib-Level (38.2, 50.0, 61.8)."""
empty = {"nearest_ratio": None, "nearest_price": None, "dist_pct": None}
if not fib or not price:
return empty
levels = fib["levels"].get(direction, {})
key = {k: v for k, v in levels.items() if k in (38.2, 50.0, 61.8)}
if not key:
return empty
nearest_ratio, nearest_price = min(key.items(), key=lambda kv: abs(kv[1] - price))
rng = fib.get("range", 1)
dist_pct = abs(nearest_price - price) / rng * 100 if rng > 0 else None
return {
"nearest_ratio": nearest_ratio,
"nearest_price": nearest_price,
"dist_pct": round(dist_pct, 1) if dist_pct is not None else None,
}
def detect_regime(angles: dict) -> str:
"""
Klassifiziert den Markt-Zustand anhand der 4 TF-Winkel.
Rückgabewerte: 'trend_up', 'trend_down', 'range', 'transition'
"""
if not angles:
return "transition"
vals = [angles.get(tf, 90.0) for tf in ("M5", "M15", "M30", "H1")]
spread = max(vals) - min(vals)
if all(v < 80 for v in vals):
return "trend_up"
if all(v > 100 for v in vals):
return "trend_down"
if spread < 25 and all(75 <= v <= 105 for v in vals):
return "range"
return "transition"
def calc_sma(values: list, period: int = 50) -> float | None:
"""Simple Moving Average über die letzten `period` Werte."""
if len(values) < period:
return None
return sum(values[-period:]) / period
def calc_vwap(bars: list) -> dict | None:
"""
Daily VWAP (Volume Weighted Average Price) aus Intraday-Bars.
Reset täglich um 00:00 UTC.
reclaim=True: Preis war in den letzten 3 Bars unter VWAP,
aktuelle Bar schloss darüber → VWAP-Reclaim-Signal.
Rückgabe: {'vwap', 'price_vs_vwap': 'above'|'below'|'at',
'reclaim': bool, 'n_bars': int, 'diff_pct': float}
"""
import time as _t
from datetime import datetime, timezone as _tz
if not bars:
return None
now_ts = _t.time()
today_utc = datetime.fromtimestamp(now_ts, tz=_tz.utc).replace(
hour=0, minute=0, second=0, microsecond=0)
today_ts = today_utc.timestamp()
today_bars = [b for b in bars if int(b["time"]) >= today_ts]
if len(today_bars) < 2:
return None
cum_tpv = 0.0
cum_vol = 0.0
vwap_series = []
for b in today_bars:
tp = (float(b["high"]) + float(b["low"]) + float(b["close"])) / 3
vol = float(b["tick_volume"] or 1)
cum_tpv += tp * vol
cum_vol += vol
vwap_series.append(cum_tpv / cum_vol if cum_vol > 0 else tp)
vwap = vwap_series[-1]
cur = float(today_bars[-1]["close"])
n = len(today_bars)
# Reclaim: letzte ≥2 Bars unter VWAP, jetzt drüber
prev_below = (n >= 3 and all(
float(today_bars[i]["close"]) < vwap_series[i]
for i in range(max(0, n - 3), n - 1)
))
reclaim = prev_below and (cur > vwap)
diff_pct = (cur - vwap) / vwap * 100 if vwap > 0 else 0.0
return {
"vwap": round(vwap, 3),
"price_vs_vwap": ("above" if cur > vwap * 1.0001 else
"below" if cur < vwap * 0.9999 else "at"),
"reclaim": reclaim,
"n_bars": n,
"diff_pct": round(diff_pct, 2),
}
def calc_sr_distance(price: float, sr: dict | None, atr: float | None) -> dict:
"""Distanz zum nächsten Support/Resistance in ATR-Einheiten."""
if not sr or not atr or atr <= 0 or not price:
return {"support_atr": None, "resistance_atr": None}
sups = sr.get("supports") or []
ress = sr.get("resistances") or []
s_dists = [price - s["price"] for s in sups if s.get("price") and s["price"] < price]
r_dists = [r["price"] - price for r in ress if r.get("price") and r["price"] > price]
return {
"support_atr": (min(s_dists) / atr) if s_dists else None,
"resistance_atr": (min(r_dists) / atr) if r_dists else None,
}
+257
View File
@@ -0,0 +1,257 @@
"""
core/analysis/m15.py — M15Analyzer und SRDetector
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.config import (
M15_BARS, EMA_FAST, EMA_SLOW, PIVOT_WINDOW, ANGLE_LR_BARS,
SR_LOOKBACK, SR_PIVOT_WIN, SR_MIN_TOUCHES, SR_TOL_ATR_FACTOR,
SR_MAX_LINES, TL_MIN_PIVOTS, CHART_BARS,
)
from core.analysis.indicators import _ema
class M15Analyzer:
"""
Erkennt M15-Trendwenden anhand von 3 Indikatoren:
1. EMA(5)/EMA(13)-Crossover
2. Bullish/Bearish Engulfing
3. Frische Swing-Highs/Lows
Mindestens 2 Indikatoren müssen in dieselbe Richtung zeigen.
"""
def __init__(self, sym):
self.symbol = sym
self.result = None
self.reasons = []
self._lock = threading.Lock()
def analyze(self):
bars = mt5.copy_rates_from_pos(self.symbol, mt5.TIMEFRAME_M15, 0, M15_BARS)
if bars is None or len(bars) < max(EMA_SLOW + 2, PIVOT_WINDOW * 2 + 2):
return
closes = [float(b["close"]) for b in bars]
opens = [float(b["open"]) for b in bars]
highs = [float(b["high"]) for b in bars]
lows = [float(b["low"]) for b in bars]
n = len(bars); w = PIVOT_WINDOW; signals = []
ef = _ema(closes, EMA_FAST)
es = _ema(closes, EMA_SLOW)
if ef[-2] < es[-2] and ef[-1] > es[-1]:
signals.append(("bullish", f"EMA{EMA_FAST}/{EMA_SLOW}"))
if ef[-2] > es[-2] and ef[-1] < es[-1]:
signals.append(("bearish", f"EMA{EMA_FAST}/{EMA_SLOW}"))
if (closes[-2] < opens[-2] and closes[-1] > opens[-1]
and closes[-1] > opens[-2] and opens[-1] < closes[-2]):
signals.append(("bullish", "Bullish Engulfing"))
if (closes[-2] > opens[-2] and closes[-1] < opens[-1]
and closes[-1] < opens[-2] and opens[-1] > closes[-2]):
signals.append(("bearish", "Bearish Engulfing"))
for i in range(n - w - 2, n - 1):
h = highs[i]
if (all(h > highs[j] for j in range(max(0, i - w), i)) and
all(h > highs[j] for j in range(i + 1, min(n, i + w + 1)))):
signals.append(("bearish", f"Swing-High (Bar -{n - 1 - i})"))
break
for i in range(n - w - 2, n - 1):
l = lows[i]
if (all(l < lows[j] for j in range(max(0, i - w), i)) and
all(l < lows[j] for j in range(i + 1, min(n, i + w + 1)))):
signals.append(("bullish", f"Swing-Low (Bar -{n - 1 - i})"))
break
bull = [r for d, r in signals if d == "bullish"]
bear = [r for d, r in signals if d == "bearish"]
direction = None; reasons = []
if len(bull) >= 2:
direction = "bullish"; reasons = bull
elif len(bear) >= 2:
direction = "bearish"; reasons = bear
with self._lock:
self.result = direction
self.reasons = reasons
def snapshot(self):
with self._lock:
return self.result, list(self.reasons)
class SRDetector:
"""
Findet horizontale Support-/Resistance-Zonen und Trendlinien auf M15.
Algorithmus:
1. Swing-Pivots erkennen (lokale Hochs/Tiefs mit Fenster ±SR_PIVOT_WIN)
2. Pivots clustern: Preise innerhalb 0.5×ATR werden zu einer Zone
3. Zonen mit >= SR_MIN_TOUCHES Berührungen → gültiges S/R-Level
4. Trendlinien: lineare Regression durch jüngste Pivot-Lows/Highs
"""
SR_DETECT_INTERVAL_S = 60
def __init__(self, symbol: str):
self.symbol = symbol
self.supports = []
self.resistances = []
self.trendline_up = None
self.trendline_dn = None
self._lock = threading.Lock()
self._last_detect_ts: float = 0
@staticmethod
def _find_pivots(highs, lows, win):
n = len(highs)
ph, pl = [], []
for i in range(win, n - win):
h = highs[i]; l = lows[i]
if (all(h >= highs[j] for j in range(i - win, i)) and
all(h >= highs[j] for j in range(i + 1, i + win + 1))):
ph.append((i, h))
if (all(l <= lows[j] for j in range(i - win, i)) and
all(l <= lows[j] for j in range(i + 1, i + win + 1))):
pl.append((i, l))
return ph, pl
@staticmethod
def _atr(highs, lows, closes, period=14):
if len(highs) < period + 1:
return None
trs = []
for i in range(1, len(highs)):
tr = max(highs[i] - lows[i],
abs(highs[i] - closes[i - 1]),
abs(lows[i] - closes[i - 1]))
trs.append(tr)
return sum(trs[-period:]) / period
@staticmethod
def _cluster(pivots, tolerance):
if not pivots:
return []
prices = sorted(p[1] for p in pivots)
clusters = []
current = [prices[0]]
for p in prices[1:]:
if abs(p - current[-1]) <= tolerance:
current.append(p)
else:
clusters.append(current)
current = [p]
clusters.append(current)
return [(sum(c) / len(c), len(c)) for c in clusters]
@staticmethod
def _trendline(pivots_recent):
if len(pivots_recent) < TL_MIN_PIVOTS:
return None
xs = [p[0] for p in pivots_recent]
ys = [p[1] for p in pivots_recent]
n = len(xs)
xm = sum(xs) / n
ym = sum(ys) / n
num = sum((xs[i] - xm) * (ys[i] - ym) for i in range(n))
den = sum((xs[i] - xm) ** 2 for i in range(n))
if den == 0:
return None
slope = num / den
intercept = ym - slope * xm
x0 = xs[0]; x1 = xs[-1]
return {
"start_idx": x0,
"end_idx": x1,
"start_price": slope * x0 + intercept,
"end_price": slope * x1 + intercept,
"slope": slope,
}
def detect(self):
now = time.monotonic()
if now - self._last_detect_ts < self.SR_DETECT_INTERVAL_S:
return
self._last_detect_ts = now
bars = mt5.copy_rates_from_pos(
self.symbol, mt5.TIMEFRAME_M15, 0, SR_LOOKBACK)
if bars is None or len(bars) < 2 * SR_PIVOT_WIN + 5:
return
highs = [float(b["high"]) for b in bars]
lows = [float(b["low"]) for b in bars]
closes = [float(b["close"]) for b in bars]
atr = self._atr(highs, lows, closes)
tol = (atr or (max(highs) - min(lows)) / 50) * SR_TOL_ATR_FACTOR
ph, pl = self._find_pivots(highs, lows, SR_PIVOT_WIN)
zones_high = [(p, n) for p, n in self._cluster(ph, tol)
if n >= SR_MIN_TOUCHES]
zones_low = [(p, n) for p, n in self._cluster(pl, tol)
if n >= SR_MIN_TOUCHES]
cur = closes[-1]
resistances = sorted(
[{"price": p, "touches": n} for p, n in zones_high if p > cur],
key=lambda z: z["price"])[:SR_MAX_LINES]
supports = sorted(
[{"price": p, "touches": n} for p, n in zones_low if p < cur],
key=lambda z: -z["price"])[:SR_MAX_LINES]
recent_cutoff = len(bars) - 50
recent_lows = [p for p in pl if p[0] >= recent_cutoff]
recent_highs = [p for p in ph if p[0] >= recent_cutoff]
tl_up = self._trendline(recent_lows[-3:]) if len(recent_lows) >= 2 else None
tl_dn = self._trendline(recent_highs[-3:]) if len(recent_highs) >= 2 else None
if tl_up and tl_up["slope"] <= 0:
tl_up = None
if tl_dn and tl_dn["slope"] >= 0:
tl_dn = None
n_bars = len(bars)
offset = n_bars - CHART_BARS
def _remap(tl):
if tl is None:
return None
si = tl["start_idx"] - offset
ei = tl["end_idx"] - offset
if tl["slope"] is not None and ei < CHART_BARS - 1:
extra = (CHART_BARS - 1) - tl["end_idx"]
ep = tl["end_price"] + tl["slope"] * extra
ei = CHART_BARS - 1
else:
ep = tl["end_price"]
if si < 0:
sp = tl["start_price"] + tl["slope"] * (offset - tl["start_idx"])
si = 0
else:
sp = tl["start_price"]
return {"start_idx": si, "end_idx": ei,
"start_price": sp, "end_price": ep}
with self._lock:
self.supports = supports
self.resistances = resistances
self.trendline_up = _remap(tl_up)
self.trendline_dn = _remap(tl_dn)
def snapshot(self):
with self._lock:
return {
"supports": list(self.supports),
"resistances": list(self.resistances),
"trendline_up": dict(self.trendline_up) if self.trendline_up else None,
"trendline_dn": dict(self.trendline_dn) if self.trendline_dn else None,
}
+76
View File
@@ -0,0 +1,76 @@
"""
core/analysis/news.py — Keyword-basiertes News-Sentiment
"""
from __future__ import annotations
# Bullische Phrasen für WTI (Angebot ↓ / Nachfrage ↑ / Risiko ↑)
NEWS_BULLISH_KW = {
"supply cut", "production cut", "opec cut", "opec+ cut", "output cut",
"sanction", "embargo", "ban on", "blockade", "shutdown", "outage",
"disruption", "halt", "force majeure", "pipeline attack",
"attack", "strike on", "missile", "drone strike", "tension escalat",
"iran tension", "houthi", "red sea", "strait of hormuz", "war",
"conflict escalat", "retaliat", "threat",
"demand growth", "demand surge", "demand rise", "demand strong",
"stockpile draw", "inventory draw", "stocks drop", "stocks fall",
"stockpiles fall", "crude draw", "cushing draw", "eia draw",
"pipeline shutdown", "refinery fire", "gulf of mexico storm",
"hurricane", "winter storm", "cold snap",
"oil surge", "oil rally", "oil jump", "oil soar", "oil spike",
"crude rise", "crude rally", "wti rise", "wti surge", "wti rally",
}
# Bärische Phrasen für WTI (Angebot ↑ / Nachfrage ↓ / Entspannung)
NEWS_BEARISH_KW = {
"supply glut", "oversupply", "production increase", "output rise",
"production boost", "opec boost", "opec+ unwind", "spr release",
"strategic reserve release", "saudi increase",
"us production record", "shale boom", "permian growth",
"demand drop", "demand fall", "demand weak", "demand slump",
"recession", "slowdown", "weak economy", "china slowdown",
"stockpile build", "inventory build", "stocks rise", "stocks build",
"crude build", "stockpiles rise", "cushing build", "eia build",
"ceasefire", "truce", "deal reached", "agreement", "diplomatic",
"talks resume", "easing tension", "sanction lift", "sanction relief",
"oil drop", "oil plunge", "oil slide", "oil fall", "oil decline",
"crude drop", "crude plunge", "wti fall", "wti slide", "oil crash",
}
def calc_news_sentiment(headlines: list, half_life_hours: float = 8.0) -> dict:
"""
Bewertet Headlines per Keyword-Matching mit altersgewichtetem Decay.
score: -1.0 (klar bärisch) … +1.0 (klar bullisch)
"""
import time as _time
if not headlines:
return {"score": 0.0, "n_bull": 0.0, "n_bear": 0.0, "samples": []}
now = _time.time()
bull_w = bear_w = 0.0
samples = []
for h in headlines:
text = (h.get("title_original") or h.get("title") or "").lower()
if not text:
continue
age_h = max(0.0, (now - h.get("ts", now)) / 3600.0)
weight = 0.5 ** (age_h / max(1.0, half_life_hours))
b = sum(1 for kw in NEWS_BULLISH_KW if kw in text)
s = sum(1 for kw in NEWS_BEARISH_KW if kw in text)
if b > s:
bull_w += weight * (1 + 0.3 * (b - 1))
samples.append(("bull", h.get("title", "")[:70]))
elif s > b:
bear_w += weight * (1 + 0.3 * (s - 1))
samples.append(("bear", h.get("title", "")[:70]))
total = bull_w + bear_w
score = 0.0 if total < 0.1 else (bull_w - bear_w) / total
return {
"score": max(-1.0, min(1.0, score)),
"n_bull": round(bull_w, 1),
"n_bear": round(bear_w, 1),
"samples": samples[:5],
}