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,201 @@
|
||||
"""
|
||||
core/tu_rating.py — Traders-Union-Analyse (tradersunion.com)
|
||||
=============================================================
|
||||
Holt die technische Analyse für WTI von der öffentlichen Traders-Union-API
|
||||
(quotes.tradersunion.com) — dieselben Daten, die der Tacho auf
|
||||
https://tradersunion.com/currencies/forecast/wti-crude-oil/signals/ anzeigt.
|
||||
|
||||
API: GET /api/v3/informer/technical-analysis/detailed/?symbol=WTI/USD
|
||||
Liefert pro Zeitebene (m5, m15, m30, h1, h4, d1, w1):
|
||||
- forecast → Gesamt-Verdikt ("Strong Sell" … "Strong Buy")
|
||||
- ta → Oszillator-Zähler (buy/sell/neutral, 13 Indikatoren)
|
||||
- ma → Moving-Average-Zähler (MA5–MA200, SMA+EMA)
|
||||
- indicators → 14 Einzelindikatoren mit Werten
|
||||
|
||||
Auto-Trader-Signal (trade_signal):
|
||||
Verdikt je TF → Score (-2 Strong Sell … +2 Strong Buy)
|
||||
LONG wenn m15 ≥ +1 UND m30 ≥ +1 UND h1 nicht dagegen (≥ 0)
|
||||
SHORT spiegelbildlich. Konfidenz steigt mit Strong-Verdikten und
|
||||
H1-/M5-Bestätigung. Daten älter als 5 min → WARTEN (kein Stale-Trading).
|
||||
|
||||
Kein API-Key nötig. fetch() blockiert (HTTP) — im Hintergrund-Thread rufen;
|
||||
snapshot()/trade_signal() liefern den letzten Stand sofort.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
import time
|
||||
|
||||
from core.logger import get_logger
|
||||
|
||||
log = get_logger("turating")
|
||||
|
||||
_API_URL = ("https://quotes.tradersunion.com/api/v3/"
|
||||
"informer/technical-analysis/detailed/")
|
||||
_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/125.0 Safari/537.36")
|
||||
|
||||
# Zeitebenen wie auf der Website: Anzeige-Key → API-Key
|
||||
INTERVALS: dict[str, str] = {
|
||||
"5m": "m5",
|
||||
"15m": "m15",
|
||||
"30m": "m30",
|
||||
"1h": "h1",
|
||||
"4h": "h4",
|
||||
"1d": "d1",
|
||||
"1w": "w1",
|
||||
}
|
||||
|
||||
# Verdikt → numerischer Score (für Nadel + Trading-Logik)
|
||||
_VERDICT_SCORE = {
|
||||
"strong sell": -2, "sell": -1, "neutral": 0, "buy": 1, "strong buy": 2,
|
||||
}
|
||||
|
||||
_STALE_S = 300 # Daten älter als 5 min → kein Trading-Signal
|
||||
|
||||
|
||||
def _vscore(verdict: str | None) -> int:
|
||||
return _VERDICT_SCORE.get((verdict or "").strip().lower(), 0)
|
||||
|
||||
|
||||
def _counts_rating(c: dict) -> float | None:
|
||||
"""Nadel-Position -1..+1 aus Buy/Sell/Neutral-Zählern."""
|
||||
total = (c.get("buy") or 0) + (c.get("sell") or 0) + (c.get("neutral") or 0)
|
||||
if not total:
|
||||
return None
|
||||
return ((c.get("buy") or 0) - (c.get("sell") or 0)) / total
|
||||
|
||||
|
||||
class TradersUnionProvider:
|
||||
"""Holt + cached die Traders-Union-Analyse für ein Symbol."""
|
||||
|
||||
def __init__(self, symbol: str = "WTI/USD"):
|
||||
self.symbol = symbol
|
||||
self._lock = threading.Lock()
|
||||
self._busy = False
|
||||
self._data: dict = {} # {interval_key: {...}}
|
||||
self._error: str | None = None
|
||||
self._last_update: float | None = None
|
||||
|
||||
# ── HTTP-Fetch (blockierend — im Hintergrund-Thread aufrufen) ───────────
|
||||
def fetch(self) -> bool:
|
||||
with self._lock:
|
||||
if self._busy:
|
||||
return False
|
||||
self._busy = True
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(
|
||||
_API_URL, params={"symbol": self.symbol},
|
||||
headers={"User-Agent": _UA, "Accept": "application/json"},
|
||||
timeout=15)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json().get("data") or {}
|
||||
|
||||
data = {}
|
||||
for key, api_key in INTERVALS.items():
|
||||
tf = payload.get(api_key)
|
||||
if not isinstance(tf, dict):
|
||||
continue
|
||||
osc = tf.get("ta") or {}
|
||||
ma = tf.get("ma") or {}
|
||||
c_osc = {"buy": osc.get("buy", 0),
|
||||
"sell": osc.get("sell", 0),
|
||||
"neutral": osc.get("neutral", 0)}
|
||||
c_ma = {"buy": ma.get("buy", 0),
|
||||
"sell": ma.get("sell", 0),
|
||||
"neutral": ma.get("neutral", 0)}
|
||||
total = {k: c_osc[k] + c_ma[k] for k in c_osc}
|
||||
data[key] = {
|
||||
"verdict": tf.get("forecast") or "—",
|
||||
"verdict_osc": osc.get("forecast") or "—",
|
||||
"verdict_ma": ma.get("forecast") or "—",
|
||||
"rating": _counts_rating(total),
|
||||
"rating_osc": _counts_rating(c_osc),
|
||||
"rating_ma": _counts_rating(c_ma),
|
||||
"counts": total,
|
||||
"counts_osc": c_osc,
|
||||
"counts_ma": c_ma,
|
||||
"score": _vscore(tf.get("forecast")),
|
||||
}
|
||||
if not data:
|
||||
raise ValueError(f"keine TF-Daten für {self.symbol}")
|
||||
with self._lock:
|
||||
self._data = data
|
||||
self._error = None
|
||||
self._last_update = time.time()
|
||||
log.info(f"{self.symbol}: " + " ".join(
|
||||
f"{k}={d['verdict']}" for k, d in data.items()))
|
||||
return True
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self._error = str(e)[:120]
|
||||
log.warning(f"TU-Analyse Fetch fehlgeschlagen: {e}")
|
||||
return False
|
||||
finally:
|
||||
with self._lock:
|
||||
self._busy = False
|
||||
|
||||
# ── Snapshot für UI (Tachos) ─────────────────────────────────────────────
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
"symbol": self.symbol,
|
||||
"intervals": dict(self._data),
|
||||
"error": self._error,
|
||||
"last_update": self._last_update,
|
||||
"busy": self._busy,
|
||||
}
|
||||
|
||||
# ── Trading-Signal für den Auto-Trader ───────────────────────────────────
|
||||
def trade_signal(self) -> dict:
|
||||
"""
|
||||
Konsens-Signal aus der Traders-Union-Analyse:
|
||||
LONG: m15 ≥ +1 und m30 ≥ +1 und h1 ≥ 0
|
||||
SHORT: m15 ≤ −1 und m30 ≤ −1 und h1 ≤ 0
|
||||
Konfidenz: 60 % Basis, +10 je Strong-Verdikt (m15/m30),
|
||||
+10 bei H1-Bestätigung, +5 bei M5-Bestätigung (max 90).
|
||||
"""
|
||||
with self._lock:
|
||||
data = dict(self._data)
|
||||
ts = self._last_update
|
||||
|
||||
base = {"signal": "WARTEN", "conf_pct": 0, "score": 0.0,
|
||||
"setup": "TU_KONSENS", "regime": None, "rsi": None,
|
||||
"reasons": []}
|
||||
if not data:
|
||||
base["reasons"] = ["keine TU-Daten"]
|
||||
return base
|
||||
if not ts or time.time() - ts > _STALE_S:
|
||||
base["reasons"] = ["TU-Daten veraltet"]
|
||||
return base
|
||||
|
||||
sc = {k: data.get(k, {}).get("score", 0) for k in
|
||||
("5m", "15m", "30m", "1h")}
|
||||
reasons = [f"{k}: {data[k]['verdict']}" for k in
|
||||
("5m", "15m", "30m", "1h", "4h", "1d") if k in data]
|
||||
|
||||
signal = "WARTEN"
|
||||
if sc["15m"] >= 1 and sc["30m"] >= 1 and sc["1h"] >= 0:
|
||||
signal = "LONG"
|
||||
elif sc["15m"] <= -1 and sc["30m"] <= -1 and sc["1h"] <= 0:
|
||||
signal = "SHORT"
|
||||
|
||||
conf = 0
|
||||
if signal != "WARTEN":
|
||||
d = 1 if signal == "LONG" else -1
|
||||
# Basis 60: mit H1-Bestätigung (+10) erreicht ein sauberer
|
||||
# Konsens die Auto-Schwelle (70) auch ohne Strong-Verdikt
|
||||
conf = 60
|
||||
conf += 10 * sum(1 for k in ("15m", "30m") if sc[k] * d >= 2)
|
||||
if sc["1h"] * d >= 1:
|
||||
conf += 10
|
||||
if sc["5m"] * d >= 1:
|
||||
conf += 5
|
||||
conf = min(conf, 90)
|
||||
|
||||
# Score -1..+1 (Mittel der Trading-TFs, normiert auf ±2)
|
||||
score = (sc["15m"] + sc["30m"] + sc["1h"]) / 6.0
|
||||
return {"signal": signal, "conf_pct": conf, "score": score,
|
||||
"setup": "TU_KONSENS", "regime": None, "rsi": None,
|
||||
"reasons": reasons}
|
||||
Reference in New Issue
Block a user