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:
+343
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
core/ai.py — KI-Marktanalyse via OpenAI ChatGPT
|
||||
=================================================
|
||||
Nutzt `gpt-4o-mini-search-preview` (oder das größere `gpt-4o-search-preview`)
|
||||
für eine Crude-Oil-Marktanalyse (WTI + Brent) mit Web-Suche.
|
||||
|
||||
Parsed eine strukturierte Antwort:
|
||||
SENTIMENT: bullish|bearish|neutral
|
||||
CONFIDENCE: 0-100
|
||||
SUMMARY: <2-3 Sätze>
|
||||
DRIVERS: <Treiber1> | <Treiber2> | <Treiber3>
|
||||
|
||||
Loggt jede Analyse in die History-DB (falls injiziert).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
import time
|
||||
|
||||
from core.logger import get_logger
|
||||
|
||||
log_ai = get_logger("ai")
|
||||
log_hist = get_logger("hist")
|
||||
|
||||
|
||||
class OpenAIAnalyzer:
|
||||
"""
|
||||
Ruft die OpenAI Chat-Completions API auf.
|
||||
|
||||
Web-Search ist bei *-search-preview-Modellen automatisch aktiv,
|
||||
konfigurierbar über `search_context` (low|medium|high).
|
||||
"""
|
||||
|
||||
PROMPT = (
|
||||
"Du bist ein professioneller Rohstoff-Marktanalyst. Suche im Web nach den "
|
||||
"wichtigsten Crude-Oil-News der letzten 24 Stunden — sowohl für "
|
||||
"WTI (US Crude) als auch Brent (OPEC, US-Lagerbestände/EIA, "
|
||||
"Geopolitik Naher Osten, Förderdaten, Nachfrage-Indikatoren, "
|
||||
"Pipelines, Raffinerien).\n\n"
|
||||
"Antworte AUSSCHLIESSLICH in genau diesem Format (deutsche Sprache):\n"
|
||||
"SENTIMENT: bullish|bearish|neutral\n"
|
||||
"CONFIDENCE: <0-100>\n"
|
||||
"SUMMARY: <2-3 Sätze, prägnant, nur preisbewegende Faktoren>\n"
|
||||
"DRIVERS: <Treiber1> | <Treiber2> | <Treiber3>\n\n"
|
||||
"Sei knapp und konkret. Keine Disclaimer, keine Einleitung."
|
||||
)
|
||||
|
||||
# Preis pro Mio Token (USD) – grobe Schätzung für Cost-Anzeige
|
||||
PRICES = {
|
||||
"gpt-4o-mini-search-preview": (0.15, 0.60, 25.0),
|
||||
"gpt-4o-search-preview": (2.50, 10.00, 30.0),
|
||||
"_default": (0.15, 0.60, 25.0),
|
||||
}
|
||||
|
||||
def __init__(self, api_key: str, model: str, search_context: str = "low"):
|
||||
self.api_key = api_key.strip()
|
||||
self.model = model.strip() or "gpt-4o-mini-search-preview"
|
||||
self.search_context = search_context.strip().lower() or "low"
|
||||
self.sentiment = None
|
||||
self.confidence = None
|
||||
self.summary = ""
|
||||
self.drivers = []
|
||||
self.last_update = None
|
||||
self.last_cost = None
|
||||
self.error = None
|
||||
self.busy = False
|
||||
self._lock = threading.Lock()
|
||||
# History-Logger wird vom main() injiziert
|
||||
self.history = None
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.api_key) and self.api_key.startswith(("sk-", "sk-proj-"))
|
||||
|
||||
def _estimate_cost(self, in_tok: int, out_tok: int) -> float:
|
||||
in_p, out_p, search_p = self.PRICES.get(self.model, self.PRICES["_default"])
|
||||
token_cost = (in_tok * in_p + out_tok * out_p) / 1_000_000
|
||||
search_cost = search_p / 1000.0
|
||||
return token_cost + search_cost
|
||||
|
||||
def analyze(self):
|
||||
if not self.is_configured():
|
||||
with self._lock:
|
||||
self.error = "Kein API-Key (config.ini)"
|
||||
return
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
with self._lock:
|
||||
self.error = "pip install openai"
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if self.busy:
|
||||
return
|
||||
self.busy = True
|
||||
self.error = None
|
||||
|
||||
try:
|
||||
client = OpenAI(api_key=self.api_key)
|
||||
kwargs = {
|
||||
"model": self.model,
|
||||
"messages": [{"role": "user", "content": self.PROMPT}],
|
||||
}
|
||||
if "search-preview" in self.model:
|
||||
kwargs["web_search_options"] = {
|
||||
"search_context_size": self.search_context,
|
||||
}
|
||||
else:
|
||||
kwargs["max_tokens"] = 700
|
||||
|
||||
res = client.chat.completions.create(**kwargs)
|
||||
text = (res.choices[0].message.content or "").strip()
|
||||
|
||||
# Strukturierte Antwort parsen
|
||||
sentiment = "neutral"
|
||||
confidence = 50
|
||||
summary = text[:300]
|
||||
drivers = []
|
||||
for raw in text.split("\n"):
|
||||
line = raw.strip()
|
||||
low = line.lower()
|
||||
if low.startswith("sentiment:"):
|
||||
v = low.split(":", 1)[1].strip()
|
||||
sentiment = ("bullish" if "bull" in v
|
||||
else "bearish" if "bear" in v else "neutral")
|
||||
elif low.startswith("confidence:"):
|
||||
digits = "".join(c for c in line.split(":", 1)[1] if c.isdigit())[:3]
|
||||
if digits:
|
||||
confidence = max(0, min(100, int(digits)))
|
||||
elif low.startswith("summary:"):
|
||||
summary = line.split(":", 1)[1].strip()
|
||||
elif low.startswith("drivers:"):
|
||||
parts = line.split(":", 1)[1]
|
||||
drivers = [d.strip() for d in parts.split("|") if d.strip()][:4]
|
||||
|
||||
# Token-Verbrauch & Kosten
|
||||
usage = getattr(res, "usage", None)
|
||||
cost_est = None
|
||||
if usage:
|
||||
in_tok = getattr(usage, "prompt_tokens", 0) or 0
|
||||
out_tok = getattr(usage, "completion_tokens", 0) or 0
|
||||
cost_est = self._estimate_cost(in_tok, out_tok)
|
||||
|
||||
with self._lock:
|
||||
self.sentiment = sentiment
|
||||
self.confidence = confidence
|
||||
self.summary = summary
|
||||
self.drivers = drivers
|
||||
self.last_update = time.time()
|
||||
self.last_cost = cost_est
|
||||
self.error = None
|
||||
|
||||
log_ai.info(f"{sentiment.upper()} ({confidence}%) — {summary[:80]}")
|
||||
if cost_est:
|
||||
log_ai.info(f"Geschätzte Kosten: ${cost_est:.4f}")
|
||||
|
||||
if self.history:
|
||||
try:
|
||||
self.history.log_ai(
|
||||
sentiment = sentiment,
|
||||
confidence = confidence,
|
||||
summary = summary,
|
||||
drivers = drivers,
|
||||
cost_estimate= cost_est,
|
||||
model = self.model,
|
||||
)
|
||||
except Exception as e:
|
||||
log_hist.error(f"log_ai: {e}")
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
with self._lock:
|
||||
self.error = err[:120]
|
||||
log_ai.error(f"Fehler: {err}")
|
||||
finally:
|
||||
with self._lock:
|
||||
self.busy = False
|
||||
|
||||
def snapshot(self):
|
||||
with self._lock:
|
||||
return {
|
||||
"sentiment": self.sentiment,
|
||||
"confidence": self.confidence,
|
||||
"summary": self.summary,
|
||||
"drivers": list(self.drivers),
|
||||
"last_update": self.last_update,
|
||||
"last_cost": self.last_cost,
|
||||
"error": self.error,
|
||||
"busy": self.busy,
|
||||
"configured": self.is_configured(),
|
||||
"model": self.model,
|
||||
}
|
||||
|
||||
|
||||
class GeminiAnalyzer:
|
||||
"""
|
||||
Ruft die Google Gemini API auf (REST, via requests).
|
||||
Nutzt Google Search Grounding für aktuelle Web-Daten.
|
||||
"""
|
||||
|
||||
PROMPT = (
|
||||
"Du bist ein professioneller Rohstoff-Marktanalyst. Suche im Web nach den "
|
||||
"wichtigsten Crude-Oil-News der letzten 24 Stunden — sowohl für "
|
||||
"WTI (US Crude) als auch Brent (OPEC, US-Lagerbestände/EIA, "
|
||||
"Geopolitik Naher Osten, Förderdaten, Nachfrage-Indikatoren, "
|
||||
"Pipelines, Raffinerien).\n\n"
|
||||
"Antworte AUSSCHLIESSLICH in genau diesem Format (deutsche Sprache):\n"
|
||||
"SENTIMENT: bullish|bearish|neutral\n"
|
||||
"CONFIDENCE: <0-100>\n"
|
||||
"SUMMARY: <2-3 Sätze, prägnant, nur preisbewegende Faktoren>\n"
|
||||
"DRIVERS: <Treiber1> | <Treiber2> | <Treiber3>\n\n"
|
||||
"Sei knapp und konkret. Keine Disclaimer, keine Einleitung."
|
||||
)
|
||||
|
||||
_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
|
||||
def __init__(self, api_key: str, model: str = "gemini-2.0-flash"):
|
||||
self.api_key = api_key.strip()
|
||||
self.model = model.strip() or "gemini-2.0-flash"
|
||||
self.sentiment = None
|
||||
self.confidence = None
|
||||
self.summary = ""
|
||||
self.drivers = []
|
||||
self.last_update = None
|
||||
self.error = None
|
||||
self.busy = False
|
||||
self._lock = threading.Lock()
|
||||
self.history = None
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.api_key) and self.api_key.startswith("AIza")
|
||||
|
||||
def analyze(self):
|
||||
if not self.is_configured():
|
||||
with self._lock:
|
||||
self.error = "Kein Gemini API-Key (config.ini)"
|
||||
return
|
||||
try:
|
||||
import requests as _req
|
||||
except ImportError:
|
||||
with self._lock:
|
||||
self.error = "pip install requests"
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if self.busy:
|
||||
return
|
||||
self.busy = True
|
||||
self.error = None
|
||||
|
||||
try:
|
||||
url = f"{self._API_BASE}/{self.model}:generateContent"
|
||||
payload = {
|
||||
"contents": [{"role": "user", "parts": [{"text": self.PROMPT}]}],
|
||||
"tools": [{"google_search": {}}],
|
||||
"generationConfig": {
|
||||
"temperature": 0.3,
|
||||
"maxOutputTokens": 600,
|
||||
},
|
||||
}
|
||||
# Key im Header statt als URL-Parameter — sonst landet er bei
|
||||
# jedem HTTP-Fehler im Klartext in der Log-Fehlermeldung (URL)
|
||||
resp = _req.post(
|
||||
url,
|
||||
headers={"x-goog-api-key": self.api_key},
|
||||
json=payload,
|
||||
timeout=60,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
|
||||
text = ""
|
||||
try:
|
||||
text = result["candidates"][0]["content"]["parts"][0]["text"].strip()
|
||||
except (KeyError, IndexError):
|
||||
text = str(result)[:300]
|
||||
|
||||
sentiment = "neutral"
|
||||
confidence = 50
|
||||
summary = text[:300]
|
||||
drivers = []
|
||||
for raw in text.split("\n"):
|
||||
line = raw.strip()
|
||||
low = line.lower()
|
||||
if low.startswith("sentiment:"):
|
||||
v = low.split(":", 1)[1].strip()
|
||||
sentiment = ("bullish" if "bull" in v
|
||||
else "bearish" if "bear" in v else "neutral")
|
||||
elif low.startswith("confidence:"):
|
||||
digits = "".join(c for c in line.split(":", 1)[1] if c.isdigit())[:3]
|
||||
if digits:
|
||||
confidence = max(0, min(100, int(digits)))
|
||||
elif low.startswith("summary:"):
|
||||
summary = line.split(":", 1)[1].strip()
|
||||
elif low.startswith("drivers:"):
|
||||
parts = line.split(":", 1)[1]
|
||||
drivers = [d.strip() for d in parts.split("|") if d.strip()][:4]
|
||||
|
||||
with self._lock:
|
||||
self.sentiment = sentiment
|
||||
self.confidence = confidence
|
||||
self.summary = summary
|
||||
self.drivers = drivers
|
||||
self.last_update = time.time()
|
||||
self.error = None
|
||||
|
||||
log_ai.info(f"[Gemini] {sentiment.upper()} ({confidence}%) — {summary[:80]}")
|
||||
|
||||
if self.history:
|
||||
try:
|
||||
self.history.log_ai(
|
||||
sentiment = sentiment,
|
||||
confidence = confidence,
|
||||
summary = summary,
|
||||
drivers = drivers,
|
||||
cost_estimate= None,
|
||||
model = self.model,
|
||||
)
|
||||
except Exception as e:
|
||||
log_hist.error(f"[Gemini] log_ai: {e}")
|
||||
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
with self._lock:
|
||||
self.error = err[:120]
|
||||
log_ai.error(f"[Gemini] Fehler: {err}")
|
||||
finally:
|
||||
with self._lock:
|
||||
self.busy = False
|
||||
|
||||
def snapshot(self):
|
||||
with self._lock:
|
||||
return {
|
||||
"sentiment": self.sentiment,
|
||||
"confidence": self.confidence,
|
||||
"summary": self.summary,
|
||||
"drivers": list(self.drivers),
|
||||
"last_update": self.last_update,
|
||||
"last_cost": None,
|
||||
"error": self.error,
|
||||
"busy": self.busy,
|
||||
"configured": self.is_configured(),
|
||||
"model": self.model,
|
||||
}
|
||||
Reference in New Issue
Block a user