""" 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: \n" "RESISTANCE_LOW: \n" "RESISTANCE_HIGH: = 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 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}