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,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",
|
||||
]
|
||||
@@ -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:00–08: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,
|
||||
}
|
||||
@@ -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. 0–100."""
|
||||
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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
Reference in New Issue
Block a user