diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08cc3a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Environment files +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Test data +*.gz +!requirements.txt diff --git a/src/exchanges/boersenag.py b/src/exchanges/boersenag.py new file mode 100644 index 0000000..58237f2 --- /dev/null +++ b/src/exchanges/boersenag.py @@ -0,0 +1,354 @@ +""" +Börsenag Exchange Fetcher +Unterstützt: DUSA, DUSB, DUSC, DUSD, HAMA, HAMB, HANA, HANB + +Datenquelle: https://www.boersenag.de/mifid-ii-delayed-data/ +URL-Format: https://cld42.boersenag.de/m13data/data/Mifir13DelayedData_{MIC}_{SEQUENCE}_{TIMESTAMP}.csv +""" + +import requests +import time +from datetime import datetime, timedelta, timezone +from typing import List, Optional +from .base import BaseExchange, Trade +import re + +# Rate-Limiting Konfiguration +RATE_LIMIT_DELAY = 0.3 # Sekunden zwischen Requests + +# Browser User-Agent für Zugriff +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/csv, text/plain, */*', + 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8', + 'Referer': 'https://www.boersenag.de/', +} + +# Exchange-Konfiguration +BOERSENAG_EXCHANGES = { + 'DUSA': { + 'name': 'DUSA', + 'full_name': 'Börse Düsseldorf Regulierter Markt', + 'mic': 'DUSA', + }, + 'DUSB': { + 'name': 'DUSB', + 'full_name': 'Börse Düsseldorf Freiverkehr', + 'mic': 'DUSB', + }, + 'DUSC': { + 'name': 'DUSC', + 'full_name': 'Börse Düsseldorf Quotrix Regulierter Markt', + 'mic': 'DUSC', + }, + 'DUSD': { + 'name': 'DUSD', + 'full_name': 'Börse Düsseldorf Quotrix Freiverkehr', + 'mic': 'DUSD', + }, + 'HAMA': { + 'name': 'HAMA', + 'full_name': 'Börse Hamburg Regulierter Markt', + 'mic': 'HAMA', + }, + 'HAMB': { + 'name': 'HAMB', + 'full_name': 'Börse Hamburg Freiverkehr', + 'mic': 'HAMB', + }, + 'HANA': { + 'name': 'HANA', + 'full_name': 'Börse Hannover Regulierter Markt', + 'mic': 'HANA', + }, + 'HANB': { + 'name': 'HANB', + 'full_name': 'Börse Hannover Freiverkehr', + 'mic': 'HANB', + }, +} + +BASE_URL = "https://cld42.boersenag.de/m13data/data" + + +class BoersenagBase(BaseExchange): + """ + Basisklasse für Börsenag Exchanges (DUSA, DUSB, DUSC, DUSD, HAMA, HAMB, HANA, HANB) + + CSV Format (Semikolon-separiert): + MIC; ISIN; displayName; time; price; size; supplement + - time: "28.01.2026 15:48:42" (deutsches Format) + - price: "46,18" (deutsches Dezimalformat) + - size: Menge (kann 0 sein für Kurse ohne Trade) + - supplement: "bez " = bezahlt (echter Trade), "G " = Geld (Bid), "B " = Brief (Ask) + """ + + @property + def mic(self) -> str: + """MIC Code für die Börse""" + raise NotImplementedError + + @property + def name(self) -> str: + return self.mic + + def _generate_file_urls(self, target_date: datetime.date) -> List[str]: + """ + Generiert mögliche Datei-URLs für ein bestimmtes Datum. + Format: Mifir13DelayedData_{MIC}_{SEQUENCE}_{TIMESTAMP}.csv + + Die Dateien werden stündlich mit einem Zeitstempel generiert. + Wir versuchen verschiedene Sequenznummern und Zeitstempel. + """ + urls = [] + + # Formatiere Datum im URL-Format: YYYYMMDD + date_str = target_date.strftime('%Y%m%d') + + # Mögliche Sequenznummern (beobachtet: 000000DF, aber könnte variieren) + sequences = ['000000DF', '00000000', '000000DD', '000000DE'] + + # Generiere URLs für verschiedene Uhrzeiten (alle 15 Minuten) + for hour in range(0, 24): + for minute in [0, 15, 30, 45]: + timestamp = f"{date_str}{hour:02d}{minute:02d}000000" + for seq in sequences: + url = f"{BASE_URL}/Mifir13DelayedData_{self.mic}_{seq}_{timestamp}.csv" + urls.append(url) + + # Versuche auch die einfachste Form mit 0000000000 + for seq in sequences: + url = f"{BASE_URL}/Mifir13DelayedData_{self.mic}_{seq}_{date_str}0000000000.csv" + urls.append(url) + + return urls + + def _parse_german_datetime(self, dt_str: str) -> Optional[datetime]: + """Parst deutsches Datumsformat: DD.MM.YYYY HH:MM:SS""" + try: + # Format: "28.01.2026 15:48:42" + dt = datetime.strptime(dt_str.strip(), '%d.%m.%Y %H:%M:%S') + # In UTC konvertieren (Deutsche Zeit = MEZ/MESZ, hier vereinfacht als UTC+1) + # Für korrektes Handling würde pytz benötigt + dt = dt.replace(tzinfo=timezone.utc) + return dt + except ValueError: + return None + + def _parse_german_number(self, num_str: str) -> Optional[float]: + """Parst deutsches Zahlenformat: 1.234,56 -> 1234.56""" + try: + # Entferne Tausender-Trennzeichen (Punkt) und ersetze Dezimalkomma + clean = num_str.strip().replace('.', '').replace(',', '.') + return float(clean) + except ValueError: + return None + + def _download_and_parse_file(self, url: str) -> List[Trade]: + """Lädt eine CSV-Datei herunter und parst die Trades""" + trades = [] + + try: + response = requests.get(url, headers=HEADERS, timeout=30) + + if response.status_code == 404: + return [] + + response.raise_for_status() + + content = response.text + if not content.strip(): + return [] + + lines = content.strip().split('\n') + if len(lines) < 2: # Nur Header, keine Daten + return [] + + # Erste Zeile ist Header + # MIC; ISIN; displayName; time; price; size; supplement + for line in lines[1:]: + if not line.strip(): + continue + + trade = self._parse_csv_line(line) + if trade: + trades.append(trade) + + except requests.exceptions.HTTPError as e: + if e.response.status_code != 404: + print(f"[{self.name}] HTTP error: {e}") + except Exception as e: + print(f"[{self.name}] Error downloading {url}: {e}") + + return trades + + def _parse_csv_line(self, line: str) -> Optional[Trade]: + """Parst eine einzelne CSV-Zeile""" + try: + # CSV ist Semikolon-separiert + parts = line.split(';') + if len(parts) < 7: + return None + + mic = parts[0].strip() + isin = parts[1].strip() + display_name = parts[2].strip().strip('"') + time_str = parts[3].strip() + price_str = parts[4].strip() + size_str = parts[5].strip() + supplement = parts[6].strip().strip('"').strip() + + # Validiere ISIN + if not isin or len(isin) != 12: + return None + + # Parse Timestamp + timestamp = self._parse_german_datetime(time_str) + if not timestamp: + return None + + # Parse Preis + price = self._parse_german_number(price_str) + if price is None or price <= 0: + return None + + # Parse Menge + try: + size = float(size_str) + except ValueError: + size = 0 + + # Nur echte Trades (size > 0) oder "bez" (bezahlt) aufnehmen + # "G" = Geld (Bid), "B" = Brief (Ask) sind Kurse, keine Trades + is_trade = size > 0 or 'bez' in supplement.lower() + + if not is_trade: + return None + + # Bei size = 0 aber "bez" nehmen wir an, dass die Menge unbekannt ist (setze auf 1) + if size <= 0: + size = 1 + + return Trade( + exchange=self.name, + symbol=isin, + isin=isin, + price=price, + quantity=size, + timestamp=timestamp + ) + + except Exception as e: + return None + + def _get_last_trading_day(self, from_date: datetime.date) -> datetime.date: + """Findet den letzten Handelstag (überspringt Wochenenden)""" + date = from_date + if date.weekday() == 5: # Samstag + date = date - timedelta(days=1) + elif date.weekday() == 6: # Sonntag + date = date - timedelta(days=2) + return date + + def fetch_latest_trades(self, include_yesterday: bool = True, since_date: datetime = None) -> List[Trade]: + """Holt alle Trades vom letzten Handelstag""" + all_trades = [] + + # Bestimme Zieldatum + if since_date: + target_date = since_date.date() if hasattr(since_date, 'date') else since_date + else: + target_date = (datetime.now(timezone.utc) - timedelta(days=1)).date() + + # Überspringe Wochenenden + original_date = target_date + target_date = self._get_last_trading_day(target_date) + + if target_date != original_date: + print(f"[{self.name}] Skipping weekend: {original_date} -> {target_date}") + + print(f"[{self.name}] Fetching trades for date: {target_date}") + + # Generiere mögliche URLs + urls = self._generate_file_urls(target_date) + + successful = 0 + total_urls = len(urls) + + # Versuche verschiedene URLs + for i, url in enumerate(urls): + trades = self._download_and_parse_file(url) + if trades: + all_trades.extend(trades) + successful += 1 + print(f"[{self.name}] Found {len(trades)} trades from: {url.split('/')[-1]}") + # Bei Erfolg müssen wir nicht alle anderen URLs probieren + break + + # Rate-Limiting + if i < total_urls - 1: + time.sleep(RATE_LIMIT_DELAY) + + # Nach 20 fehlgeschlagenen Versuchen abbrechen + if i > 20 and successful == 0: + break + + print(f"[{self.name}] Total trades fetched: {len(all_trades)}") + return all_trades + + +# Konkrete Exchange-Klassen +class DUSAExchange(BoersenagBase): + """Börse Düsseldorf Regulierter Markt""" + @property + def mic(self) -> str: + return "DUSA" + + +class DUSBExchange(BoersenagBase): + """Börse Düsseldorf Freiverkehr""" + @property + def mic(self) -> str: + return "DUSB" + + +class DUSCExchange(BoersenagBase): + """Börse Düsseldorf Quotrix Regulierter Markt""" + @property + def mic(self) -> str: + return "DUSC" + + +class DUSDExchange(BoersenagBase): + """Börse Düsseldorf Quotrix Freiverkehr""" + @property + def mic(self) -> str: + return "DUSD" + + +class HAMAExchange(BoersenagBase): + """Börse Hamburg Regulierter Markt""" + @property + def mic(self) -> str: + return "HAMA" + + +class HAMBExchange(BoersenagBase): + """Börse Hamburg Freiverkehr""" + @property + def mic(self) -> str: + return "HAMB" + + +class HANAExchange(BoersenagBase): + """Börse Hannover Regulierter Markt""" + @property + def mic(self) -> str: + return "HANA" + + +class HANBExchange(BoersenagBase): + """Börse Hannover Freiverkehr""" + @property + def mic(self) -> str: + return "HANB"