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>
202 lines
7.9 KiB
Python
202 lines
7.9 KiB
Python
"""
|
||
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}
|