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>
160 lines
6.5 KiB
Python
160 lines
6.5 KiB
Python
"""
|
|
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}
|