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>
253 lines
8.3 KiB
Python
253 lines
8.3 KiB
Python
"""
|
||
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,
|
||
}
|