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
+159
View File
@@ -0,0 +1,159 @@
"""
core/daily_levels.py — Tägliche WTI-Intraday-Level per Web-Recherche
=====================================================================
Holt einmal morgens (Engine-Scheduler) über **z.ai/GLM mit Web-Suche**
(`web_search`-Tool, Thinking aus) die heutigen Schlüssel-Level (Support /
Widerstandsband / Bias) für WTI und liefert sie als **Kontext**: fließen in
S/R-Anzeige, Close-Alarme und Verdict — NICHT in die Handelsrichtung
(Dritt-Prognose, kein gemessener Edge).
Strenge Plausibilitätsprüfung gegen den aktuellen Kurs verhindert, dass
halluzinierte Zahlen als Zonen landen (fail-safe: bei Zweifel kein Update).
"""
from __future__ import annotations
import threading
import time
from core.logger import get_logger
log = get_logger("daily")
_MAX_DEV = 0.12 # Level muss innerhalb ±12 % des Kurses liegen (sonst verworfen)
_PROMPT = (
"Du bist Rohstoff-Marktanalyst. Suche im Web die heutige INTRADAY-Lage für "
"WTI-Rohöl (US Crude, aktueller Kurs ~{price:.2f} USD). Nenne die für HEUTE "
"wichtigsten technischen Level rund um den aktuellen Kurs.\n\n"
"Antworte AUSSCHLIESSLICH in genau diesem Format (nur Zahlen, Punkt als "
"Dezimaltrenner, deutsche Sprache):\n"
"SUPPORT: <preis unter dem Kurs>\n"
"RESISTANCE_LOW: <preis über dem Kurs>\n"
"RESISTANCE_HIGH: <preis über dem Kurs, >= RESISTANCE_LOW>\n"
"BIAS: bullish|bearish|neutral\n"
"SUMMARY: <1-2 Sätze, nur preisbewegende Faktoren>\n\n"
"Die Level müssen NAHE am aktuellen Kurs liegen (Intraday, keine Jahresziele). "
"Keine Disclaimer, keine Einleitung."
)
class DailyLevels:
def __init__(self, api_key: str, base_url: str = "https://api.z.ai/api/paas/v4",
model: str = "glm-4.5-flash"):
self.api_key = (api_key or "").strip()
self.base_url = (base_url or "https://api.z.ai/api/paas/v4").rstrip("/")
self.model = (model or "glm-4.5-flash").strip()
self.support: float | None = None
self.res_lo: float | None = None
self.res_hi: float | None = None
self.bias: str = "neutral"
self.summary: str = ""
self.ts: float = 0.0
self.error: str | None = None
self._lock = threading.Lock()
def is_configured(self) -> bool:
return bool(self.api_key)
@staticmethod
def _num(line: str):
s = line.split(":", 1)[1] if ":" in line else line
s = s.replace(",", ".")
buf = ""
for c in s:
if c.isdigit() or c == ".":
buf += c
elif buf:
break
try:
return float(buf)
except ValueError:
return None
def _call_llm(self, prompt: str) -> str:
"""z.ai/GLM (OpenAI-kompatibel) mit Web-Suche + Thinking AUS (sonst geht
das Token-Budget komplett ins „Denken")."""
import requests
resp = requests.post(
self.base_url + "/chat/completions",
headers={"Authorization": "Bearer " + self.api_key},
json={"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"tools": [{"type": "web_search",
"web_search": {"enable": True, "search_result": True}}],
"thinking": {"type": "disabled"},
"max_tokens": 500},
timeout=90)
resp.raise_for_status()
return (resp.json()["choices"][0]["message"].get("content") or "").strip()
def run(self, price: float) -> bool:
"""Holt + validiert die Tages-Level. price = aktueller Kurs (Anker +
Plausibilitätsbasis). True bei erfolgreichem, plausiblem Update."""
if not self.is_configured():
with self._lock:
self.error = "Kein z.ai-Key"
return False
if not price or price <= 0:
with self._lock:
self.error = "Kein Kurs als Anker"
return False
try:
text = self._call_llm(_PROMPT.format(price=price))
sup = rlo = rhi = None
bias, summary = "neutral", ""
for raw in text.split("\n"):
low = raw.strip().lower()
if low.startswith("support:"): sup = self._num(raw)
elif low.startswith("resistance_low:"): rlo = self._num(raw)
elif low.startswith("resistance_high:"): rhi = self._num(raw)
elif low.startswith("bias:"):
v = low.split(":", 1)[1]
bias = ("bullish" if "bull" in v else
"bearish" if "bear" in v else "neutral")
elif low.startswith("summary:"):
summary = raw.split(":", 1)[1].strip()
ok, why = self._validate(price, sup, rlo, rhi)
if not ok:
with self._lock:
self.error = f"unplausibel: {why}"
log.warning(f"Tages-Level verworfen ({why}) — "
f"sup={sup} rlo={rlo} rhi={rhi} bei Kurs {price:.2f}")
return False
with self._lock:
self.support, self.res_lo, self.res_hi = sup, rlo, rhi
self.bias, self.summary = bias, summary
self.ts, self.error = time.time(), None
log.info(f"Tages-Level: Sup {sup:.2f} · Res {rlo:.2f}-{rhi:.2f} · "
f"Bias {bias} · {summary[:70]}")
return True
except Exception as e:
with self._lock:
self.error = str(e)[:120]
log.warning(f"DailyLevels.run: {e}")
return False
def _validate(self, price, sup, rlo, rhi):
if sup is None or rlo is None or rhi is None:
return False, "Level fehlen"
if not (sup < price < rhi):
return False, "Reihenfolge support<Kurs<resistance verletzt"
if rlo > rhi:
return False, "res_low>res_high"
for lv in (sup, rlo, rhi):
if abs(lv - price) / price > _MAX_DEV:
return False, f"{lv} >{_MAX_DEV*100:.0f}% vom Kurs entfernt"
return True, ""
def zone_lines(self) -> list[float]:
with self._lock:
return [x for x in (self.support, self.res_lo, self.res_hi)
if x is not None]
def snapshot(self) -> dict:
with self._lock:
return {"support": self.support, "res_lo": self.res_lo,
"res_hi": self.res_hi, "bias": self.bias,
"summary": self.summary, "ts": self.ts, "error": self.error}