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:
+123
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
core/gaps.py — GapAnalyzer
|
||||
============================
|
||||
Erkennt ungefüllte Kurslücken (Gaps) im Tageschart (D1) und liefert ihre
|
||||
Fill-Target-Levels als S/R-Kandidaten (Magnete). Gleiche Rolle wie `daily_levels`
|
||||
/ `SRDetector`: NUR Kontext/Anzeige + Konfidenz (über `_sr_levels` → Wellen-
|
||||
Konfidenz → Autotrader), KEINE eigene Handelsrichtung.
|
||||
|
||||
Gap-Definition (Vakuum zwischen Vortag und Folgetag):
|
||||
UP : Low(heute) > High(gestern) → Lücke [High_gestern … Low_heute], füllt bei High_gestern
|
||||
DOWN : High(heute) < Low(gestern) → Lücke [High_heute … Low_gestern], füllt bei Low_gestern
|
||||
„Gefüllt" = ein späterer Bar handelt wieder in/durch das Vakuum.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
import time
|
||||
import datetime as dt
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
|
||||
from core.mt5_utils import mt5_lock
|
||||
from core.logger import get_logger
|
||||
|
||||
log = get_logger("gaps")
|
||||
|
||||
_BROKER_OFFSET_S = 3 * 3600 # Brokerzeit (UTC+3) → ~UTC für die Datums-Zuordnung
|
||||
|
||||
|
||||
class GapAnalyzer:
|
||||
"""Thread-sicher, fail-safe. `maybe_refresh()` drosselt den MT5-Zugriff."""
|
||||
|
||||
def __init__(self, refresh_s: int = 600, lookback_days: int = 400,
|
||||
year: int | None = None):
|
||||
self._lock = threading.Lock()
|
||||
self._gaps: list[dict] = [] # offene (ungefüllte) Gaps
|
||||
self._n_total = 0
|
||||
self._ts = 0.0
|
||||
self._next_run = 0.0
|
||||
self._refresh_s = refresh_s
|
||||
self._lookback = lookback_days
|
||||
self._year = year # None = aktuelles Jahr
|
||||
|
||||
def maybe_refresh(self, sym: str | None):
|
||||
"""Im Loop aufrufen — holt die D1-Bars höchstens alle `refresh_s`."""
|
||||
if not sym or time.time() < self._next_run:
|
||||
return
|
||||
self._next_run = time.time() + self._refresh_s
|
||||
self.detect(sym)
|
||||
|
||||
def detect(self, sym: str):
|
||||
try:
|
||||
with mt5_lock(timeout=3) as got:
|
||||
if not got:
|
||||
self._next_run = time.time() + 30 # gleich nochmal versuchen
|
||||
return
|
||||
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_D1, 0, self._lookback)
|
||||
if bars is None or len(bars) < 2:
|
||||
return
|
||||
year = self._year or dt.datetime.now().year
|
||||
rows = []
|
||||
for b in bars:
|
||||
d = dt.datetime.fromtimestamp(
|
||||
int(b["time"]) - _BROKER_OFFSET_S, tz=dt.timezone.utc).date()
|
||||
rows.append({"date": d, "h": float(b["high"]),
|
||||
"l": float(b["low"]), "c": float(b["close"])})
|
||||
rows = [r for r in rows if r["date"].year == year]
|
||||
if len(rows) < 2:
|
||||
return
|
||||
|
||||
gaps = []
|
||||
for i in range(1, len(rows)):
|
||||
p, cur = rows[i - 1], rows[i]
|
||||
if cur["l"] > p["h"]:
|
||||
gaps.append({"i": i, "date": cur["date"].isoformat(), "dir": "up",
|
||||
"lo": round(p["h"], 3), "hi": round(cur["l"], 3),
|
||||
"fill": round(p["h"], 3)})
|
||||
elif cur["h"] < p["l"]:
|
||||
gaps.append({"i": i, "date": cur["date"].isoformat(), "dir": "down",
|
||||
"lo": round(cur["h"], 3), "hi": round(p["l"], 3),
|
||||
"fill": round(p["l"], 3)})
|
||||
|
||||
openg = []
|
||||
for g in gaps:
|
||||
later = rows[g["i"] + 1:]
|
||||
if g["dir"] == "up":
|
||||
mn = min((r["l"] for r in later), default=g["hi"])
|
||||
filled = mn <= g["lo"]
|
||||
else:
|
||||
mx = max((r["h"] for r in later), default=g["lo"])
|
||||
filled = mx >= g["hi"]
|
||||
g["size"] = round(g["hi"] - g["lo"], 3)
|
||||
if not filled:
|
||||
g.pop("i", None)
|
||||
openg.append(g)
|
||||
|
||||
with self._lock:
|
||||
self._gaps = openg
|
||||
self._n_total = len(gaps)
|
||||
self._ts = time.time()
|
||||
log.info(f"Gaps {year}: {len(gaps)} gesamt, {len(openg)} offen")
|
||||
except Exception as e:
|
||||
log.warning(f"GapAnalyzer.detect: {e}")
|
||||
|
||||
def zone_lines(self) -> list[float]:
|
||||
"""Fill-Target-Levels (für die S/R-Kandidaten / Magnete)."""
|
||||
with self._lock:
|
||||
return [g["fill"] for g in self._gaps]
|
||||
|
||||
def nearest(self, price: float | None) -> dict:
|
||||
"""Nächstes offenes Gap-Fill-Level über/unter dem Preis."""
|
||||
if price is None:
|
||||
return {"above": None, "below": None}
|
||||
with self._lock:
|
||||
fills = [g["fill"] for g in self._gaps]
|
||||
above = sorted(f for f in fills if f > price)
|
||||
below = sorted((f for f in fills if f < price), reverse=True)
|
||||
return {"above": above[0] if above else None,
|
||||
"below": below[0] if below else None}
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
return {"gaps": list(self._gaps), "n_open": len(self._gaps),
|
||||
"n_total": self._n_total, "last_update": self._ts}
|
||||
Reference in New Issue
Block a user