""" 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, }