Add Börsenag exchanges (DUSA, DUSB, DUSC, DUSD, HAMA, HAMB, HANA, HANB)

- New boersenag.py with support for Düsseldorf, Hamburg, and Hannover exchanges
- Proper .gitignore to exclude venv and temp files
This commit is contained in:
Melchior Reimers
2026-01-29 16:08:14 +01:00
parent a07319d957
commit a21e036bb4
2 changed files with 402 additions and 0 deletions

48
.gitignore vendored Normal file
View File

@@ -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

354
src/exchanges/boersenag.py Normal file
View File

@@ -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"