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