aalienau-cmyk commited on
Commit
16bbbb2
·
0 Parent(s):

Initial commit: Trading Bot v1.0 - Paper Trading mit KI

Browse files
.env.example ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # KI Provider: "anthropic", "openai" oder "ollama"
2
+ AI_PROVIDER=anthropic
3
+ AI_MODEL_NAME=
4
+
5
+ # Supabase
6
+ SUPABASE_URL=https://your-project.supabase.co
7
+ SUPABASE_KEY=your-anon-key
8
+
9
+ # API Keys (je nach Provider)
10
+ ANTHROPIC_API_KEY=
11
+ OPENAI_API_KEY=
12
+ OLLAMA_BASE_URL=http://localhost:11434/api/generate
13
+
14
+ # Logging
15
+ LOG_LEVEL=INFO
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ venv/
6
+ *.egg-info/
7
+ dist/
8
+ build/
Procfile ADDED
@@ -0,0 +1 @@
 
 
1
+ web: uvicorn app:app --host 0.0.0.0 --port 7860
README.md ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Autonomer Krypto-Trading-Bot
2
+
3
+ Ein zweistufiger autonomer Krypto-Trading-Bot:
4
+
5
+ 1. **Screener** (Top 100 Coins, regelbasiert, kein KI)
6
+ 2. **KI-Entscheidung** (Entry/Exit durch LLM)
7
+ 3. **Paper-Trading** (Supabase, kein echtes Geld)
8
+ 4. **24/7** auf Hugging Face Spaces, getriggert von Cron-Job.org
9
+
10
+ ## Tech Stack
11
+
12
+ | Komponente | Dienst |
13
+ |---|---|
14
+ | Hosting | Hugging Face Spaces |
15
+ | Trigger | Cron-Job.org (alle 5 Min) |
16
+ | Marktdaten | Binance Public API |
17
+ | KI | Anthropic / OpenAI / Ollama (konfigurierbar) |
18
+ | Datenbank | Supabase (PostgreSQL) |
19
+
20
+ ## Setup
21
+
22
+ ### 1. Lokale Entwicklung
23
+
24
+ ```bash
25
+ python -m venv venv
26
+ source venv/bin/activate # oder venv\Scripts\activate auf Windows
27
+ pip install -r requirements.txt
28
+ ```
29
+
30
+ ### 2. Umgebungsvariablen
31
+
32
+ Kopiere `.env.example` zu `.env` und fülle die Werte:
33
+
34
+ ```env
35
+ AI_PROVIDER=anthropic
36
+ AI_MODEL_NAME=claude-sonnet-4-20250514
37
+
38
+ SUPABASE_URL=https://your-project.supabase.co
39
+ SUPABASE_KEY=your-anon-key
40
+
41
+ ANTHROPIC_API_KEY=sk-ant-...
42
+ ```
43
+
44
+ ### 3. Supabase Schema
45
+
46
+ Führe `schema.sql` im Supabase SQL Editor aus.
47
+
48
+ ### 4. Lokale Tests
49
+
50
+ ```bash
51
+ python run_screener_test.py # Phase 1: Screener
52
+ python run_ai_test.py # Phase 2: KI
53
+ python run_db_test.py # Phase 3: DB
54
+ python run_exit_test.py # Phase 4: Exit
55
+ ```
56
+
57
+ ### 5. Hugging Face Deployment
58
+
59
+ 1. GitHub Repo erstellen und pushen
60
+ 2. Hugging Face Space erstellen (Free Tier)
61
+ 3. GitHub Repo mit Space verknüpfen
62
+ 4. Secrets eintragen:
63
+ - `ANTHROPIC_API_KEY`
64
+ - `SUPABASE_URL`
65
+ - `SUPABASE_KEY`
66
+ - `AI_PROVIDER`
67
+ - `AI_MODEL_NAME`
68
+ 5. Cron-Job.org einrichten: `POST https://<user>-trading-bot.hf.space/scan` alle 5 Min
69
+
70
+ ## KI Provider wechseln
71
+
72
+ Der Bot unterstützt drei KI-Provider:
73
+
74
+ ```env
75
+ # Anthropic (Standard)
76
+ AI_PROVIDER=anthropic
77
+ AI_MODEL_NAME=claude-sonnet-4-20250514
78
+
79
+ # OpenAI
80
+ AI_PROVIDER=openai
81
+ AI_MODEL_NAME=gpt-4o-mini
82
+
83
+ # Ollama (lokal)
84
+ AI_PROVIDER=ollama
85
+ AI_MODEL_NAME=llama3.2
86
+ ```
87
+
88
+ ## API Endpoints
89
+
90
+ | Endpoint | Methode | Beschreibung |
91
+ |---|---|---|
92
+ | `/` | GET | Health Check |
93
+ | `/scan` | POST | Bot-Zyklus ausführen |
94
+ | `/positions` | GET | Offene Positionen |
95
+ | `/status` | GET | Bot Status |
96
+
97
+ ## Phasen
98
+
99
+ - [x] Phase 1: Lokaler Screener-Test
100
+ - [x] Phase 2: KI lokal integrieren
101
+ - [x] Phase 3: Supabase anbinden
102
+ - [x] Phase 4: Exit-Logik integrieren
103
+ - [x] Phase 5: Hugging Face Deployment
104
+
105
+ ## Lizenz
106
+
107
+ Privat — nicht für Produktion ohne Tests.
ai/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """ai — KI-Entscheidungen für Entry und Exit."""
ai/entry_prompt.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ entry_prompt.py — KI-Prompt für Kaufentscheidung.
3
+
4
+ Baut den Prompt aus den Screener-Daten und ruft die KI auf.
5
+ Parst die Antwort als JSON.
6
+ """
7
+
8
+ import logging
9
+ from datetime import datetime
10
+
11
+ from ai.provider import get_provider, parse_json_response, AIProviderError
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # ── System Prompt ──
16
+ SYSTEM_PROMPT = """Du bist die analytische Entscheidungs-Engine für einen autonomen
17
+ Trading-Agenten. Du bewertest ausschließlich die gelieferten Daten —
18
+ keine Meinungen, keine Halluzinationen.
19
+ Wenn das Setup nicht eindeutig ist, entscheidest du dich für SKIP.
20
+ SKIP ist immer die sichere Wahl.
21
+
22
+ RISIKOREGEL (nicht verhandelbar):
23
+ - Stop-Loss: 1.5x bis 2.5x ATR unterhalb des Entry-Preises.
24
+ - Take-Profit 1: mindestens 2.0x den Stop-Loss-Abstand (R:R ≥ 1:2).
25
+ - Take-Profit 2: mindestens 3.5x den Stop-Loss-Abstand (R:R ≥ 1:3.5).
26
+ - Wenn diese Levels durch aktuelle Widerstände unrealistisch sind → SKIP.
27
+ - Bei confidence < 7 ist die Entscheidung automatisch SKIP."""
28
+
29
+
30
+ def build_entry_prompt(
31
+ symbol: str,
32
+ price: float,
33
+ position_size_usd: float,
34
+ ohlcv_data: list,
35
+ indicators: dict,
36
+ screener_reason: str,
37
+ btc_context: dict
38
+ ) -> tuple[str, str]:
39
+ """
40
+ Baut den Entry-Prompt.
41
+
42
+ Args:
43
+ symbol: Coin Symbol
44
+ price: Aktueller Preis
45
+ position_size_usd: Berechnete Positionsgröße
46
+ ohlcv_data: Letzte 10 OHLCV-Kerzen
47
+ indicators: Dict mit RSI, MACD, EMA, Volume, ATR
48
+ screener_reason: Begründung des Screener (z.B. "Volume_Spike_2.3x, MACD_Cross")
49
+ btc_context: Dict mit btc_4h_pct, btc_above_ema50
50
+
51
+ Returns:
52
+ Tuple von (system_prompt, user_prompt)
53
+ """
54
+ # OHLCV Tabelle als Text
55
+ ohlcv_table = "Zeit | Open | High | Low | Close | Volume\n"
56
+ ohlcv_table += "-" * 50 + "\n"
57
+ for kline in ohlcv_data[-10:]:
58
+ from datetime import datetime
59
+ dt = datetime.fromtimestamp(kline[0] / 1000).strftime("%H:%M")
60
+ ohlcv_table += (
61
+ f"{dt} | {kline[1]:.4f} | {kline[2]:.4f} | "
62
+ f"{kline[3]:.4f} | {kline[4]:.4f} | {kline[5]:.0f}\n"
63
+ )
64
+
65
+ user_prompt = f"""ASSET: {symbol} | TIMEFRAME: 15min
66
+ AKTUELLER PREIS: {price} USD
67
+ BERECHNETE POSITIONSGRÖSSE: {position_size_usd} USD
68
+
69
+ TECHNISCHE DATEN (Letzte 10 Kerzen, OHLCV):
70
+ {ohlcv_table}
71
+
72
+ INDIKATOREN:
73
+ - RSI (14): {indicators.get('rsi', 'N/A')}
74
+ - MACD: Line={indicators.get('macd', {}).get('line', 'N/A')}, Signal={indicators.get('macd', {}).get('signal', 'N/A')}, Histogram={indicators.get('macd', {}).get('histogram', 'N/A')}
75
+ - EMA: 9={indicators.get('ema9', 'N/A')}, 21={indicators.get('ema21', 'N/A')}, 50={indicators.get('ema50', 'N/A')}
76
+ - Volume Ratio (vs. 20-Candle-Avg): {indicators.get('volume_ratio_20', 'N/A')}x
77
+ - Volume Ratio (vs. 5-Candle-Avg): {indicators.get('volume_ratio_5', 'N/A')}x
78
+ - ATR (14, 15min): {indicators.get('atr', 'N/A')} USD
79
+
80
+ SCREENER-BEGRÜNDUNG:
81
+ {screener_reason}
82
+
83
+ MARKTKONTEXT:
84
+ - BTC 4h Change: {btc_context.get('btc_4h_pct', 'N/A')}%
85
+ - BTC über EMA 50 (4h): {btc_context.get('btc_above_ema50', 'N/A')}
86
+
87
+ Antworte AUSSCHLIESSLICH in diesem JSON-Format, kein Text davor/danach:
88
+ {{
89
+ "decision": "BUY" | "SKIP",
90
+ "confidence": 1-10,
91
+ "entry_price": float,
92
+ "stop_loss": float,
93
+ "take_profit_1": float,
94
+ "take_profit_2": float,
95
+ "risk_reward_ratio": float,
96
+ "learning_tags": ["string"],
97
+ "reason": "Maximal 2 Sätze."
98
+ }}
99
+
100
+ Mögliche learning_tags (Array, mehrere erlaubt):
101
+ "Volume_Breakout", "MACD_Cross", "RSI_Reversal",
102
+ "RSI_Momentum", "Trend_Continuation", "EMA_Alignment", "Mixed_Signal\""""
103
+
104
+ return SYSTEM_PROMPT, user_prompt
105
+
106
+
107
+ def get_entry_decision(
108
+ symbol: str,
109
+ price: float,
110
+ position_size_usd: float,
111
+ ohlcv_data: list,
112
+ indicators: dict,
113
+ screener_reason: str,
114
+ btc_context: dict
115
+ ) -> dict:
116
+ """
117
+ Ruft KI für Entry-Entscheidung auf.
118
+
119
+ Args:
120
+ symbol: Coin Symbol
121
+ price: Aktueller Preis
122
+ position_size_usd: Berechnete Positionsgröße
123
+ ohlcv_data: OHLCV-Kerzen
124
+ indicators: Indikatoren-Dict
125
+ screener_reason: Screener-Begründung
126
+ btc_context: BTC-Kontext
127
+
128
+ Returns:
129
+ Dict mit decision, confidence, entry_price, stop_loss, etc.
130
+ Bei Fehler: {"decision": "SKIP", "reason": "..."}
131
+ """
132
+ system_prompt, user_prompt = build_entry_prompt(
133
+ symbol, price, position_size_usd, ohlcv_data,
134
+ indicators, screener_reason, btc_context
135
+ )
136
+
137
+ try:
138
+ provider = get_provider()
139
+ response = provider.generate(system_prompt, user_prompt)
140
+ result = parse_json_response(response)
141
+
142
+ # Normalisieren
143
+ result["decision"] = result.get("decision", "SKIP").upper()
144
+ result["confidence"] = int(result.get("confidence", 0))
145
+
146
+ logger.info(
147
+ f"KI Entry {symbol}: {result['decision']} "
148
+ f"(Confidence: {result['confidence']})"
149
+ )
150
+ return result
151
+
152
+ except AIProviderError as e:
153
+ logger.error(f"KI Entry Fehler für {symbol}: {e}")
154
+ return {
155
+ "decision": "SKIP",
156
+ "confidence": 0,
157
+ "reason": f"KI_ERROR: {e}"
158
+ }
159
+ except ValueError as e:
160
+ logger.error(f"KI Entry JSON-Fehler für {symbol}: {e}")
161
+ return {
162
+ "decision": "SKIP",
163
+ "confidence": 0,
164
+ "reason": f"JSON_PARSE_ERROR: {e}"
165
+ }
ai/exit_prompt.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ exit_prompt.py — KI-Prompt für Verkaufsentscheidung.
3
+
4
+ Wird nur aufgerufen wenn der Sell-Screener Score 2-3 hat (ambivalent).
5
+ """
6
+
7
+ import logging
8
+ from datetime import datetime
9
+
10
+ from ai.provider import get_provider, parse_json_response, AIProviderError
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # ── System Prompt ──
15
+ SYSTEM_PROMPT = """Du bist die analytische Exit-Engine für einen autonomen Trading-Agenten.
16
+ Du entscheidest ob eine offene Position geschlossen werden soll.
17
+ Ein technischer Screener hat ambivalente Warnsignale gesendet.
18
+
19
+ SICHERHEITSREGEL (nicht verhandelbar):
20
+ - Bei confidence < 6 ist die Entscheidung automatisch SELL.
21
+ Im Zweifel schützt man Kapital.
22
+ - new_trailing_stop_usd darf NIEMALS über dem aktuellen Preis liegen
23
+ und NIEMALS schlechter als der aktuell gesetzte Trailing-Stop sein."""
24
+
25
+
26
+ def build_exit_prompt(
27
+ symbol: str,
28
+ entry_price: float,
29
+ current_price: float,
30
+ pnl_percent: float,
31
+ duration_minutes: int,
32
+ initial_stop: float,
33
+ current_trailing_stop: float | None,
34
+ tp1_hit: bool,
35
+ hold_count: int,
36
+ ohlcv_data: list,
37
+ indicators: dict,
38
+ sell_reason: str,
39
+ btc_context: dict
40
+ ) -> tuple[str, str]:
41
+ """
42
+ Baut den Exit-Prompt.
43
+
44
+ Returns:
45
+ Tuple von (system_prompt, user_prompt)
46
+ """
47
+ # OHLCV Tabelle
48
+ ohlcv_table = "Zeit | Open | High | Low | Close | Volume\n"
49
+ ohlcv_table += "-" * 50 + "\n"
50
+ for kline in ohlcv_data[-10:]:
51
+ dt = datetime.fromtimestamp(kline[0] / 1000).strftime("%H:%M")
52
+ ohlcv_table += (
53
+ f"{dt} | {kline[1]:.4f} | {kline[2]:.4f} | "
54
+ f"{kline[3]:.4f} | {kline[4]:.4f} | {kline[5]:.0f}\n"
55
+ )
56
+
57
+ tp1_status = "Ja" if tp1_hit else "Nein"
58
+ trailing_str = f"{current_trailing_stop:.4f}" if current_trailing_stop else "Nicht aktiv"
59
+
60
+ user_prompt = f"""POSITION:
61
+ - Asset: {symbol}
62
+ - Entry-Preis: {entry_price} USD
63
+ - Aktueller Preis: {current_price} USD
64
+ - Aktueller P&L: {pnl_percent}%
65
+ - Haltedauer: {duration_minutes} Minuten
66
+ - Initialer Stop-Loss: {initial_stop} USD
67
+ - Aktueller Trailing-Stop: {trailing_str} USD
68
+ - TP1 bereits erreicht: {tp1_status}
69
+ - HOLD-Entscheidungen in Folge: {hold_count}
70
+
71
+ TECHNISCHE DATEN (Letzte 10 Kerzen, 15min):
72
+ {ohlcv_table}
73
+
74
+ INDIKATOREN:
75
+ - RSI (14): {indicators.get('rsi', 'N/A')}
76
+ - MACD: Line={indicators.get('macd', {}).get('line', 'N/A')}, Signal={indicators.get('macd', {}).get('signal', 'N/A')}, Histogram={indicators.get('macd', {}).get('histogram', 'N/A')}
77
+ - EMA 9: {indicators.get('ema9', 'N/A')} | Preis vs. EMA 9: {'über' if current_price > indicators.get('ema9', 0) else 'unter'}
78
+ - Volume Ratio (vs. 20-Candle-Avg): {indicators.get('volume_ratio_20', 'N/A')}x
79
+ - ATR (14, 15min): {indicators.get('atr', 'N/A')} USD
80
+
81
+ SCREENER-WARNUNG:
82
+ {sell_reason}
83
+
84
+ MARKTKONTEXT:
85
+ - BTC 15min Change: {btc_context.get('btc_15min_pct', 'N/A')}%
86
+ - BTC 1h Change: {btc_context.get('btc_1h_pct', 'N/A')}%
87
+
88
+ Antworte AUSSCHLIESSLICH in diesem JSON-Format, kein Text davor/danach:
89
+ {{
90
+ "decision": "SELL" | "HOLD",
91
+ "confidence": 1-10,
92
+ "new_trailing_stop_usd": float | null,
93
+ "learning_tags": ["string"],
94
+ "reason": "Maximal 2 Sätze."
95
+ }}
96
+
97
+ Mögliche learning_tags:
98
+ "Hold_Normal_Pullback", "Hold_Trend_Intact",
99
+ "Sell_Momentum_Loss", "Sell_MACD_Reversal",
100
+ "Sell_Volume_Collapse", "Sell_Mixed_Signals", "Sell_Overbought\""""
101
+
102
+ return SYSTEM_PROMPT, user_prompt
103
+
104
+
105
+ def get_exit_decision(
106
+ symbol: str,
107
+ entry_price: float,
108
+ current_price: float,
109
+ pnl_percent: float,
110
+ duration_minutes: int,
111
+ initial_stop: float,
112
+ current_trailing_stop: float | None,
113
+ tp1_hit: bool,
114
+ hold_count: int,
115
+ ohlcv_data: list,
116
+ indicators: dict,
117
+ sell_reason: str,
118
+ btc_context: dict
119
+ ) -> dict:
120
+ """
121
+ Ruft KI für Exit-Entscheidung auf.
122
+
123
+ Returns:
124
+ Dict mit decision, confidence, new_trailing_stop_usd, etc.
125
+ Bei Fehler: {"decision": "HOLD", "reason": "..."}
126
+ """
127
+ system_prompt, user_prompt = build_exit_prompt(
128
+ symbol, entry_price, current_price, pnl_percent,
129
+ duration_minutes, initial_stop, current_trailing_stop,
130
+ tp1_hit, hold_count, ohlcv_data, indicators,
131
+ sell_reason, btc_context
132
+ )
133
+
134
+ try:
135
+ provider = get_provider()
136
+ response = provider.generate(system_prompt, user_prompt)
137
+ result = parse_json_response(response)
138
+
139
+ # Normalisieren
140
+ result["decision"] = result.get("decision", "HOLD").upper()
141
+ result["confidence"] = int(result.get("confidence", 0))
142
+
143
+ logger.info(
144
+ f"KI Exit {symbol}: {result['decision']} "
145
+ f"(Confidence: {result['confidence']})"
146
+ )
147
+ return result
148
+
149
+ except AIProviderError as e:
150
+ logger.error(f"KI Exit Fehler für {symbol}: {e}")
151
+ return {
152
+ "decision": "HOLD",
153
+ "confidence": 0,
154
+ "reason": f"KI_ERROR: {e}"
155
+ }
156
+ except ValueError as e:
157
+ logger.error(f"KI Exit JSON-Fehler für {symbol}: {e}")
158
+ return {
159
+ "decision": "HOLD",
160
+ "confidence": 0,
161
+ "reason": f"JSON_PARSE_ERROR: {e}"
162
+ }
ai/provider.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ provider.py — KI Provider-Abstraktionsschicht.
3
+
4
+ Ermöglicht einfachen Wechsel zwischen Anthropic, OpenAI und Ollama.
5
+ Kein Code-Änderung nötig — nur Umgebungsvariablen ändern.
6
+
7
+ Usage:
8
+ from ai.provider import get_provider
9
+ provider = get_provider()
10
+ response = provider.generate(system_prompt, user_prompt)
11
+ """
12
+
13
+ import os
14
+ import json
15
+ import logging
16
+ import requests
17
+
18
+ import config
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class AIProvider:
24
+ """Basisklasse für KI-Provider."""
25
+
26
+ def generate(self, system_prompt: str, user_prompt: str) -> str:
27
+ """
28
+ Sendet Prompt an KI und gibt Antwort zurück.
29
+
30
+ Args:
31
+ system_prompt: System-Anweisung
32
+ user_prompt: User-Eingabe
33
+
34
+ Returns:
35
+ Antwort-Text der KI
36
+
37
+ Raises:
38
+ AIProviderError: Bei Fehlern
39
+ """
40
+ raise NotImplementedError
41
+
42
+
43
+ class AnthropicProvider(AIProvider):
44
+ """Anthropic Claude API Provider (inkl. OpenRouter Kompatibilitaet)."""
45
+
46
+ def __init__(self):
47
+ import anthropic
48
+
49
+ api_key = os.getenv("ANTHROPIC_API_KEY")
50
+ if not api_key:
51
+ raise AIProviderError("ANTHROPIC_API_KEY nicht gesetzt")
52
+
53
+ # OpenRouter unterstuetzt Anthropic-kompatible APIs
54
+ base_url = os.getenv("ANTHROPIC_BASE_URL", "")
55
+
56
+ if base_url:
57
+ self.client = anthropic.Anthropic(api_key=api_key, base_url=base_url)
58
+ else:
59
+ self.client = anthropic.Anthropic(api_key=api_key)
60
+
61
+ self.model = config.AI_MODEL_NAME or "claude-sonnet-4-20250514"
62
+
63
+ def generate(self, system_prompt: str, user_prompt: str) -> str:
64
+ try:
65
+ response = self.client.messages.create(
66
+ model=self.model,
67
+ max_tokens=1024,
68
+ system=system_prompt,
69
+ messages=[{"role": "user", "content": user_prompt}]
70
+ )
71
+ return response.content[0].text
72
+ except Exception as e:
73
+ raise AIProviderError(f"Anthropic API Fehler: {e}") from e
74
+
75
+
76
+ class OpenAIProvider(AIProvider):
77
+ """OpenAI API Provider."""
78
+
79
+ def __init__(self):
80
+ import openai
81
+
82
+ api_key = os.getenv("OPENAI_API_KEY")
83
+ if not api_key:
84
+ raise AIProviderError("OPENAI_API_KEY nicht gesetzt")
85
+
86
+ self.client = openai.OpenAI(api_key=api_key)
87
+ self.model = config.AI_MODEL_NAME or "gpt-4o-mini"
88
+
89
+ def generate(self, system_prompt: str, user_prompt: str) -> str:
90
+ try:
91
+ response = self.client.chat.completions.create(
92
+ model=self.model,
93
+ messages=[
94
+ {"role": "system", "content": system_prompt},
95
+ {"role": "user", "content": user_prompt}
96
+ ],
97
+ max_tokens=1024
98
+ )
99
+ return response.choices[0].message.content
100
+ except Exception as e:
101
+ raise AIProviderError(f"OpenAI API Fehler: {e}") from e
102
+
103
+
104
+ class OllamaProvider(AIProvider):
105
+ """Lokaler Ollama Provider (für lokales Testen ohne API-Kosten)."""
106
+
107
+ def __init__(self):
108
+ self.base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
109
+ self.model = config.AI_MODEL_NAME or "llama3.2"
110
+
111
+ def generate(self, system_prompt: str, user_prompt: str) -> str:
112
+ url = f"{self.base_url}/api/generate"
113
+ full_prompt = f"{system_prompt}\n\n{user_prompt}"
114
+
115
+ try:
116
+ response = requests.post(url, json={
117
+ "model": self.model,
118
+ "prompt": full_prompt,
119
+ "stream": False
120
+ }, timeout=30)
121
+ response.raise_for_status()
122
+ return response.json().get("response", "")
123
+ except Exception as e:
124
+ raise AIProviderError(f"Ollama API Fehler: {e}") from e
125
+
126
+
127
+ class AIProviderError(Exception):
128
+ """Fehler bei KI-Provider."""
129
+ pass
130
+
131
+
132
+ def get_provider() -> AIProvider:
133
+ """
134
+ Factory-Funktion: Erstellt den konfigurierten KI-Provider.
135
+
136
+ Returns:
137
+ AIProvider Instanz
138
+
139
+ Raises:
140
+ AIProviderError: Bei unbekanntem Provider
141
+ """
142
+ provider_type = config.AI_PROVIDER.lower()
143
+
144
+ if provider_type == "anthropic":
145
+ return AnthropicProvider()
146
+ elif provider_type == "openai":
147
+ return OpenAIProvider()
148
+ elif provider_type == "ollama":
149
+ return OllamaProvider()
150
+ else:
151
+ raise AIProviderError(
152
+ f"Unbekannter AI_PROVIDER: '{provider_type}'. "
153
+ f"Erlaubt: 'anthropic', 'openai', 'ollama'"
154
+ )
155
+
156
+
157
+ def parse_json_response(response_text: str) -> dict:
158
+ """
159
+ Extrahiert JSON aus KI-Antwort.
160
+
161
+ KI manchmal Text vor/nach dem JSON. Diese Funktion
162
+ findet und parst das JSON robust.
163
+
164
+ Args:
165
+ response_text: Rohe KI-Antwort
166
+
167
+ Returns:
168
+ Geparstes Dict
169
+
170
+ Raises:
171
+ ValueError: Wenn kein gültiges JSON gefunden
172
+ """
173
+ # Versuch 1: Direkt parsen
174
+ try:
175
+ return json.loads(response_text.strip())
176
+ except json.JSONDecodeError:
177
+ pass
178
+
179
+ # Versuch 2: JSON in Code-Block finden (```json ... ```)
180
+ import re
181
+ code_block = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', response_text, re.DOTALL)
182
+ if code_block:
183
+ try:
184
+ return json.loads(code_block.group(1))
185
+ except json.JSONDecodeError:
186
+ pass
187
+
188
+ # Versuch 3: Erstes { ... } finden
189
+ json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
190
+ if json_match:
191
+ try:
192
+ return json.loads(json_match.group(0))
193
+ except json.JSONDecodeError:
194
+ pass
195
+
196
+ raise ValueError(f"Kein gültiges JSON in KI-Antwort gefunden: {response_text[:200]}")
ai/validator.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ validator.py — Backend-Validation für KI-Entscheidungen.
3
+
4
+ Wird NACH der KI, VOR dem Paper-Trade ausgeführt.
5
+ Härtet Filter — auch wenn KI "BUY" sagt, kann der Validator blockieren.
6
+ """
7
+
8
+ import logging
9
+ from datetime import datetime, timezone
10
+
11
+ import config
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def validate_entry_decision(decision: dict, current_price: float) -> tuple[bool, str]:
17
+ """
18
+ Validiert KI Entry-Entscheidung.
19
+
20
+ Args:
21
+ decision: KI-Output Dict
22
+ current_price: Aktueller Preis
23
+
24
+ Returns:
25
+ Tuple von (is_valid, reason)
26
+ """
27
+ # Entscheidung muss BUY sein
28
+ if decision.get("decision") != "BUY":
29
+ return False, f"NOT_BUY: {decision.get('decision')}"
30
+
31
+ # Confidence Check
32
+ confidence = decision.get("confidence", 0)
33
+ if confidence < config.ENTRY_MIN_CONFIDENCE:
34
+ return False, f"CONFIDENCE_TOO_LOW: {confidence} < {config.ENTRY_MIN_CONFIDENCE}"
35
+
36
+ # Stop Loss muss unter Entry sein
37
+ entry_price = decision.get("entry_price", current_price)
38
+ stop_loss = decision.get("stop_loss", 0)
39
+
40
+ if stop_loss <= 0:
41
+ return False, "INVALID_STOP_LOSS: Kein Stop Loss gesetzt"
42
+
43
+ if stop_loss >= entry_price:
44
+ return False, f"STOP_ABOVE_ENTRY: SL={stop_loss} >= Entry={entry_price}"
45
+
46
+ # Risk-Reward Check
47
+ sl_distance = entry_price - stop_loss
48
+ tp1 = decision.get("take_profit_0", 0)
49
+ if tp1 <= 0:
50
+ tp1 = decision.get("take_profit_1", 0)
51
+ tp1_distance = tp1 - entry_price
52
+
53
+ if sl_distance <= 0:
54
+ return False, "INVALID_SL_DISTANCE"
55
+
56
+ rr_ratio = tp1_distance / sl_distance
57
+ if rr_ratio < config.ENTRY_MIN_RR:
58
+ return False, f"RR_TOO_LOW: {rr_ratio:.2f} < {config.ENTRY_MIN_RR}"
59
+
60
+ # Position Size Check
61
+ position_size = decision.get("position_size_usd", 0)
62
+ if position_size <= 0:
63
+ return False, "INVALID_POSITION_SIZE"
64
+
65
+ max_size = config.PORTFOLIO_VALUE_USD * 0.10
66
+ if position_size > max_size:
67
+ return False, f"POSITION_TOO_LARGE: {position_size} > {max_size}"
68
+
69
+ logger.info(
70
+ f"Entry validiert: {decision.get('symbol', '?')} | "
71
+ f"Confidence: {confidence} | R:R: {rr_ratio:.2f}"
72
+ )
73
+ return True, "OK"
74
+
75
+
76
+ def validate_exit_decision(
77
+ decision: dict,
78
+ current_price: float,
79
+ current_trailing_stop: float | None,
80
+ hold_count: int
81
+ ) -> str:
82
+ """
83
+ Validiert KI Exit-Entscheidung.
84
+
85
+ Args:
86
+ decision: KI-Output Dict
87
+ current_price: Aktueller Preis
88
+ current_trailing_stop: Aktueller Trailing Stop (oder None)
89
+ hold_count: Anzahl HOLD-Entscheidungen in Folge
90
+
91
+ Returns:
92
+ "SELL" oder "HOLD"
93
+ """
94
+ # Zwangsverkauf nach 3x HOLD
95
+ if hold_count >= config.MAX_HOLD_COUNT:
96
+ logger.info(f"Exit: Zwangsverkauf nach {hold_count}x HOLD")
97
+ return "SELL"
98
+
99
+ # Confidence zu niedrig → SELL
100
+ confidence = decision.get("confidence", 0)
101
+ if confidence < config.EXIT_MIN_CONFIDENCE:
102
+ logger.info(f"Exit: Confidence {confidence} < {config.EXIT_MIN_CONFIDENCE} → SELL")
103
+ return "SELL"
104
+
105
+ # Trailing Stop Validierung
106
+ new_stop = decision.get("new_trailing_stop_usd")
107
+ if new_stop is not None:
108
+ # Neuer Stop darf nicht über aktuellem Preis liegen
109
+ if new_stop >= current_price:
110
+ logger.warning(
111
+ f"Exit: Neuer Trailing Stop {new_stop} >= Preis {current_price} → ignoriert"
112
+ )
113
+ decision["new_trailing_stop_usd"] = None
114
+
115
+ # Neuer Stop darf nicht schlechter als aktueller
116
+ elif current_trailing_stop and new_stop < current_trailing_stop:
117
+ logger.warning(
118
+ f"Exit: Neuer Trailing Stop {new_stop} < aktueller {current_trailing_stop} → ignoriert"
119
+ )
120
+ decision["new_trailing_stop_usd"] = None
121
+
122
+ return decision.get("decision", "HOLD")
123
+
124
+
125
+ def calculate_position_size(entry_price: float, stop_loss: float) -> float:
126
+ """
127
+ Berechnet Positionsgröße basierend auf Risikoprozentsatz.
128
+
129
+ Formel:
130
+ risk_amount = portfolio_value * (risk_per_trade_pct / 100)
131
+ position_size = risk_amount / sl_distance
132
+ capped = min(position_size * entry_price, portfolio_value * 0.10)
133
+
134
+ Args:
135
+ entry_price: Einstiegspreis
136
+ stop_loss: Stop Loss Preis
137
+
138
+ Returns:
139
+ Positionsgröße in USD
140
+ """
141
+ if entry_price <= 0 or stop_loss <= 0:
142
+ return 0.0
143
+
144
+ sl_distance = entry_price - stop_loss
145
+ if sl_distance <= 0:
146
+ return 0.0
147
+
148
+ risk_amount = config.PORTFOLIO_VALUE_USD * (config.RISK_PER_TRADE_PCT / 100)
149
+ position_size = risk_amount / sl_distance
150
+ position_value = position_size * entry_price
151
+
152
+ # Cap bei 10% des Portfolios
153
+ max_value = config.PORTFOLIO_VALUE_USD * 0.10
154
+ final_value = min(position_value, max_value)
155
+
156
+ logger.info(
157
+ f"Positionsgröße: ${final_value:.2f} "
158
+ f"(Risk: ${risk_amount:.2f}, SL Distance: ${sl_distance:.4f})"
159
+ )
160
+ return round(final_value, 2)
app.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py — FastAPI Hauptapp für Hugging Face Space.
3
+
4
+ Entry Point für den Bot. Hugging Face ruft diese Datei auf.
5
+ Cron-Job.org triggert POST /scan alle 5 Minuten.
6
+ """
7
+
8
+ import logging
9
+ import sys
10
+ import os
11
+ import traceback
12
+ from datetime import datetime, timezone
13
+
14
+ # Sicherstellen dass das Projekt-Verzeichnis im sys.path ist
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+
17
+ from fastapi import FastAPI
18
+ from fastapi.responses import JSONResponse
19
+
20
+ from screener.market_gate import market_gate_check
21
+ from data.universe import get_top100_universe
22
+ from screener.screener import run_screener
23
+ from ai.entry_prompt import get_entry_decision
24
+ from ai.validator import validate_entry_decision, calculate_position_size
25
+ from positions.manager import open_position, close_position, close_partial_position, get_all_open_positions
26
+ from positions.exit_logic import process_exit
27
+ from db.supabase_client import (
28
+ log_scan, log_btc_price, get_open_positions,
29
+ get_open_position_count, is_configured as db_configured
30
+ )
31
+ from data.binance import get_current_price
32
+
33
+ # Logging
34
+ logging.basicConfig(
35
+ level=logging.INFO,
36
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
37
+ )
38
+ logger = logging.getLogger(__name__)
39
+
40
+ app = FastAPI(title="Trading Bot", version="1.0.0")
41
+
42
+
43
+ @app.get("/")
44
+ def health_check():
45
+ """Health Check — schläft HF Space nicht ein."""
46
+ return {
47
+ "status": "healthy",
48
+ "service": "trading-bot",
49
+ "timestamp": datetime.now(timezone.utc).isoformat()
50
+ }
51
+
52
+
53
+ @app.get("/health")
54
+ def health():
55
+ """Kurzer Health Check."""
56
+ return {"ok": True}
57
+
58
+
59
+ @app.post("/scan")
60
+ async def scan():
61
+ """
62
+ Kompletter Bot-Zyklus.
63
+
64
+ 1. Market Gate (BTC-Filter)
65
+ 2. Universe laden
66
+ 3. Screener
67
+ 4. KI Entry + Validation
68
+ 5. Exit-Check für offene Positionen
69
+ 6. Logging
70
+ """
71
+ try:
72
+ return await _run_scan_cycle()
73
+ except Exception as e:
74
+ logger.error(f"CRITICAL: Scan cycle failed: {e}")
75
+ logger.error(traceback.format_exc())
76
+ return JSONResponse(
77
+ content={"status": "error", "message": str(e)},
78
+ status_code=500
79
+ )
80
+
81
+
82
+ async def _run_scan_cycle() -> dict:
83
+ """Interne Scan-Logik."""
84
+ result = {
85
+ "status": "ok",
86
+ "timestamp": datetime.now(timezone.utc).isoformat(),
87
+ "market_gate_passed": False,
88
+ "market_gate_reason": "",
89
+ "candidates_found": 0,
90
+ "trades_opened": 0,
91
+ "trades_closed": 0,
92
+ "errors": []
93
+ }
94
+
95
+ # ── BTC Preis loggen ──
96
+ try:
97
+ btc_price = get_current_price("BTCUSDT")
98
+ if db_configured():
99
+ log_btc_price(btc_price)
100
+ except Exception as e:
101
+ logger.warning(f"BTC-Preis konnte nicht geladen werden: {e}")
102
+ btc_price = 0
103
+
104
+ # ── Stage 0: Market Gate ──
105
+ gate_result = market_gate_check()
106
+ result["market_gate_passed"] = gate_result.passed
107
+ result["market_gate_reason"] = gate_result.reason
108
+
109
+ if not gate_result.passed:
110
+ logger.info(f"Market Gate BLOCKED: {gate_result.reason}")
111
+ if db_configured():
112
+ log_scan(result)
113
+ return result
114
+
115
+ # ── Stage 1: Universe + Screener ──
116
+ try:
117
+ universe = get_top100_universe()
118
+ except Exception as e:
119
+ result["errors"].append(f"Universe Fehler: {e}")
120
+ if db_configured():
121
+ log_scan(result)
122
+ return result
123
+
124
+ # Blacklist aus DB
125
+ blacklist = set()
126
+ if db_configured():
127
+ # Blacklist wird im Screener gefiltert
128
+ pass
129
+
130
+ candidates = run_screener(universe, blacklist=blacklist)
131
+ result["candidates_found"] = len(candidates)
132
+
133
+ # ── Stage 2+3: KI Entry + Validation ──
134
+ trades_opened = 0
135
+ for candidate in candidates:
136
+ # Berechne Positionsgröße
137
+ # (vereinfacht: fixe Größe basierend auf Portfolio)
138
+ position_size = 50.0 # $50 pro Trade als Default
139
+
140
+ # KI Entry Decision
141
+ btc_context = {
142
+ "btc_4h_pct": gate_result.btc_4h_change_pct,
143
+ "btc_above_ema50": gate_result.btc_above_ema50
144
+ }
145
+
146
+ try:
147
+ from data.binance import get_klines
148
+ klines = get_klines(candidate.symbol, "15m", 100)
149
+
150
+ decision = get_entry_decision(
151
+ symbol=candidate.symbol,
152
+ price=candidate.price,
153
+ position_size_usd=position_size,
154
+ ohlcv_data=klines,
155
+ indicators=candidate.indicators,
156
+ screener_reason=", ".join(candidate.reasons),
157
+ btc_context=btc_context
158
+ )
159
+ except Exception as e:
160
+ logger.error(f"KI Entry Fehler für {candidate.symbol}: {e}")
161
+ continue
162
+
163
+ # Validation
164
+ is_valid, reason = validate_entry_decision(decision, candidate.price)
165
+ if not is_valid:
166
+ logger.info(f"Entry abgelehnt für {candidate.symbol}: {reason}")
167
+ continue
168
+
169
+ # Max Positionen prüfen
170
+ open_count = get_open_position_count()
171
+ if open_count >= 3: # MAX_OPEN_POSITIONS
172
+ logger.info(f"Max Positionen erreicht, {candidate.symbol} übersprungen")
173
+ continue
174
+
175
+ # Position öffnen
176
+ trade_id = open_position(
177
+ symbol=candidate.symbol,
178
+ entry_price=decision.get("entry_price", candidate.price),
179
+ position_size_usd=position_size,
180
+ stop_loss=decision["stop_loss"],
181
+ take_profit_1=decision["take_profit_1"],
182
+ take_profit_2=decision["take_profit_2"],
183
+ entry_atr=candidate.atr,
184
+ risk_reward_ratio=decision.get("risk_reward_ratio", 0),
185
+ learning_tags=decision.get("learning_tags", [])
186
+ )
187
+
188
+ if trade_id:
189
+ trades_opened += 1
190
+
191
+ result["trades_opened"] = trades_opened
192
+
193
+ # ── Exit-Check für offene Positionen ──
194
+ trades_closed = 0
195
+ open_positions = get_all_open_positions()
196
+
197
+ for position in open_positions:
198
+ try:
199
+ current_price = get_current_price(position["symbol"])
200
+
201
+ btc_context = {
202
+ "btc_15min_pct": 0, # TODO: implement
203
+ "btc_1h_pct": 0
204
+ }
205
+
206
+ exit_decision = process_exit(position, current_price, btc_context)
207
+
208
+ if exit_decision == "SELL":
209
+ success = close_position(
210
+ trade_id=position["id"],
211
+ exit_price=current_price,
212
+ exit_reason="KI_Exit" if "KI" in str(position.get("exit_reason", "")) else "Signal",
213
+ symbol=position["symbol"]
214
+ )
215
+ if success:
216
+ trades_closed += 1
217
+
218
+ elif exit_decision == "PARTIAL_SELL":
219
+ success = close_partial_position(
220
+ trade_id=position["id"],
221
+ exit_price=current_price,
222
+ exit_reason="TP1_Partial_Exit",
223
+ symbol=position["symbol"]
224
+ )
225
+ if success:
226
+ trades_closed += 1
227
+
228
+ except Exception as e:
229
+ logger.error(f"Exit-Check Fehler für {position['symbol']}: {e}")
230
+
231
+ result["trades_closed"] = trades_closed
232
+
233
+ # ── Scan loggen ──
234
+ if db_configured():
235
+ log_scan(result)
236
+
237
+ logger.info(
238
+ f"Scan abgeschlossen: "
239
+ f"Candidates={result['candidates_found']}, "
240
+ f"Opened={result['trades_opened']}, "
241
+ f"Closed={result['trades_closed']}"
242
+ )
243
+
244
+ return result
245
+
246
+
247
+ @app.get("/positions")
248
+ def positions():
249
+ """Gibt alle offenen Positionen zurück."""
250
+ open_pos = get_all_open_positions()
251
+ return {"positions": open_pos, "count": len(open_pos)}
252
+
253
+
254
+ @app.get("/status")
255
+ def status():
256
+ """Bot Status."""
257
+ return {
258
+ "status": "running",
259
+ "supabase_configured": db_configured(),
260
+ "open_positions": get_open_position_count()
261
+ }
262
+
263
+
264
+ if __name__ == "__main__":
265
+ import uvicorn
266
+ uvicorn.run(app, host="0.0.0.0", port=7860)
config.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ config.py — Zentrale Konfiguration für den Trading Bot.
3
+
4
+ Alle Parameter an einem Ort. Niemals Werte direkt im Code hardcoden.
5
+ Secrets kommen aus Umgebungsvariablen oder .env Datei.
6
+ """
7
+
8
+ import os
9
+ from dotenv import load_dotenv
10
+
11
+ # .env Datei laden (für lokale Entwicklung)
12
+ load_dotenv()
13
+
14
+
15
+ # ──────────────────────────────────────────────
16
+ # Binance API
17
+ # ──────────────────────────────────────────────
18
+ BINANCE_BASE_URL = "https://api.binance.com"
19
+ BINANCE_TIMEOUT_SECONDS = 10
20
+ BINANCE_RETRIES = 3
21
+ BINANCE_BACKOFF_BASE = 1.5 # Exponential Backoff Basis in Sekunden
22
+
23
+
24
+ # ──────────────────────────────────────────────
25
+ # Screener
26
+ # ──────────────────────────────────────────────
27
+ SCREENER_INTERVAL_MINUTES = 15
28
+ EXIT_CHECK_INTERVAL_MINUTES = 5
29
+ UNIVERSE_SIZE = 100
30
+ MIN_LIQUIDITY_USD = 5_000_000
31
+ SCREENER_PASS_SCORE = 6
32
+ MAX_CANDIDATES_PER_CYCLE = 3
33
+
34
+ # FOMO-Schutz: Preis +10% in letzten 8 Kerzen → Ausschluss
35
+ FOMO_PRICE_RISE_PCT = 10.0
36
+ FOMO_LOOKBACK_CANDLES = 8
37
+
38
+ # Volume Spike Schwellen
39
+ VOLUME_SPIKE_THRESHOLD = 1.5
40
+
41
+ # Momentum
42
+ MOMENTUM_MIN_PCT = 1.5
43
+ MOMENTUM_MAX_PCT = 5.0
44
+ MOMENTUM_LOOKBACK_CANDLES = 8
45
+
46
+
47
+ # ──────────────────────────────────────────────
48
+ # Positionen
49
+ # ──────────────────────────────────────────────
50
+ MAX_OPEN_POSITIONS = 3
51
+ PORTFOLIO_VALUE_USD = 1000.0
52
+ RISK_PER_TRADE_PCT = 1.5
53
+ COOLDOWN_AFTER_SL_HOURS = 4
54
+
55
+
56
+ # ──────────────────────────────────────────────
57
+ # ATR-Multiplikatoren
58
+ # ──────────────────────────────────────────────
59
+ ATR_STOP_LOSS_MULT = 1.5
60
+ ATR_TP1_MULT = 3.0 # R:R 1:2
61
+ ATR_TP2_MULT = 5.25 # R:R 1:3.5
62
+ ATR_TRAILING_ACTIVATION_MULT = 1.5
63
+ ATR_TRAILING_DISTANCE_MULT = 1.5
64
+
65
+
66
+ # ──────────────────────────────────────────────
67
+ # Entry KI
68
+ # ──────────────────────────────────────────────
69
+ ENTRY_MIN_CONFIDENCE = 7
70
+ ENTRY_MIN_RR = 2.0
71
+
72
+
73
+ # ──────────────────────────────────────────────
74
+ # Exit KI
75
+ # ──────────────────────────────────────────────
76
+ EXIT_MIN_CONFIDENCE = 6
77
+ MAX_HOLD_COUNT = 3
78
+
79
+
80
+ # ──────────────────────────────────────────────
81
+ # BTC Filter (Stage 0)
82
+ # ──────────────────────────────────────────────
83
+ BTC_FILTER_4H_DROP = -2.5
84
+ BTC_FILTER_4H_PUMP = 4.0
85
+ BTC_CRASH_FAST_PCT = -1.5 # 15min Change
86
+ BTC_CRASH_CONFIRM_PCT = -2.5 # 1h Change
87
+
88
+
89
+ # ──────────────────────────────────────────────
90
+ # Time Stops
91
+ # ──────────────────────────────────────────────
92
+ TIME_STOP_A_HOURS = 4
93
+ TIME_STOP_A_PNL_PCT = -0.5
94
+ TIME_STOP_B_HOURS = 2
95
+
96
+
97
+ # ──────────────────────────────────────────────
98
+ # KI Provider (austauschbar)
99
+ # ──────────────────────────────────────────────
100
+ AI_PROVIDER = os.getenv("AI_PROVIDER", "anthropic") # "anthropic", "openai", "ollama"
101
+ AI_MODEL_NAME = os.getenv("AI_MODEL_NAME", "") # Leer = Provider-Default
102
+
103
+
104
+ # ──────────────────────────────────────────────
105
+ # Supabase
106
+ # ──────────────────────────────────────────────
107
+ SUPABASE_URL = os.getenv("SUPABASE_URL", "")
108
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
109
+
110
+
111
+ # ──────────────────────────────────────────────
112
+ # Stablecoins & Leveraged Tokens (Ausschluss)
113
+ # ────────────────────────────────��─────────────
114
+ STABLECOINS = {"USDT", "USDC", "BUSD", "DAI", "TUSD", "FDUSD", "USDD", "TUSDE"}
115
+ LEVERAGED_TOKEN_PATTERNS = {"UP", "DOWN", "3L", "3S", "BEAR", "BULL"}
116
+
117
+
118
+ # ──────────────────────────────────────────────
119
+ # Logging
120
+ # ──────────────────────────────────────────────
121
+ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
dashboard/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """dashboard — Streamlit Dashboard."""
dashboard/dashboard.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ dashboard.py — Streamlit Dashboard für den Trading Bot.
3
+
4
+ Zeigt offene Positionen, Trade-Historie und Scan-Logs.
5
+ Starten mit: streamlit run dashboard/dashboard.py
6
+ """
7
+
8
+ import streamlit as st
9
+ import pandas as pd
10
+ from datetime import datetime, timezone
11
+
12
+ from db.supabase_client import (
13
+ get_open_positions, get_portfolio_value, is_configured
14
+ )
15
+
16
+ st.set_page_config(page_title="Trading Bot Dashboard", layout="wide")
17
+ st.title("🤖 Trading Bot Dashboard")
18
+
19
+ # ── Status ──
20
+ col1, col2, col3 = st.columns(3)
21
+
22
+ with col1:
23
+ st.metric("Status", "🟢 Online" if is_configured() else "🔴 Nicht konfiguriert")
24
+
25
+ with col2:
26
+ portfolio_value = get_portfolio_value()
27
+ st.metric("Portfolio-Wert", f"${portfolio_value:,.2f}")
28
+
29
+ with col3:
30
+ open_count = len(get_open_positions())
31
+ st.metric("Offene Positionen", open_count)
32
+
33
+ st.divider()
34
+
35
+ # ── Offene Positionen ──
36
+ st.subheader("📈 Offene Positionen")
37
+
38
+ positions = get_open_positions()
39
+ if positions:
40
+ df = pd.DataFrame(positions)
41
+ df = df[["symbol", "entry_price", "position_size_usd", "stop_loss",
42
+ "take_profit_1", "opened_at", "learning_tags"]]
43
+ st.dataframe(df, use_container_width=True)
44
+ else:
45
+ info = st.info("Keine offenen Positionen")
46
+
47
+ st.divider()
48
+
49
+ # ── Hinweise ──
50
+ st.caption(
51
+ f"Letzte Aktualisierung: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC"
52
+ )
data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """data — Marktdaten von Binance API."""
data/binance.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ binance.py — Binance Public API Wrapper.
3
+
4
+ Kein Account nötig. Alle Endpoints sind öffentlich.
5
+ Inkl. Error Handling und Exponential Backoff bei Rate Limits.
6
+ """
7
+
8
+ import time
9
+ import logging
10
+ import requests
11
+
12
+ import config
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class BinanceAPIError(Exception):
18
+ """Fehler bei Binance API Aufruf."""
19
+ pass
20
+
21
+
22
+ class BinanceRateLimitError(BinanceAPIError):
23
+ """Binance Rate Limit wurde erreicht."""
24
+ pass
25
+
26
+
27
+ def _request_with_retry(url: str, params: dict = None) -> dict | list:
28
+ """
29
+ GET-Request an Binance API mit Exponential Backoff.
30
+
31
+ Raises:
32
+ BinanceAPIError: Bei Timeout oder unerwartetem Fehler
33
+ BinanceRateLimitError: Bei Rate Limit (429)
34
+ """
35
+ backoff = config.BINANCE_BACKOFF_BASE
36
+
37
+ for attempt in range(config.BINANCE_RETRIES):
38
+ try:
39
+ response = requests.get(
40
+ url,
41
+ params=params,
42
+ timeout=config.BINANCE_TIMEOUT_SECONDS
43
+ )
44
+
45
+ if response.status_code == 429:
46
+ logger.warning(
47
+ f"Binance Rate Limit (Versuch {attempt + 1}/{config.BINANCE_RETRIES}). "
48
+ f"Warte {backoff:.1f}s..."
49
+ )
50
+ time.sleep(backoff)
51
+ backoff *= 2
52
+ continue
53
+
54
+ response.raise_for_status()
55
+ return response.json()
56
+
57
+ except requests.exceptions.Timeout:
58
+ logger.warning(
59
+ f"Binance Timeout (Versuch {attempt + 1}/{config.BINANCE_RETRIES}). "
60
+ f"Warte {backoff:.1f}s..."
61
+ )
62
+ if attempt < config.BINANCE_RETRIES - 1:
63
+ time.sleep(backoff)
64
+ backoff *= 2
65
+ else:
66
+ raise BinanceAPIError("Binance API Timeout nach allen Retries")
67
+
68
+ except requests.exceptions.RequestException as e:
69
+ raise BinanceAPIError(f"Binance Request Fehler: {e}")
70
+
71
+ raise BinanceRateLimitError("Binance Rate Limit — alle Retries aufgebraucht")
72
+
73
+
74
+ def get_ticker_24hr() -> list[dict]:
75
+ """
76
+ Gibt alle 24h Ticker zurück.
77
+
78
+ Returns:
79
+ Liste von Dicts mit symbol, volume, quoteVolume, etc.
80
+ """
81
+ url = f"{config.BINANCE_BASE_URL}/api/v3/ticker/24hr"
82
+ return _request_with_retry(url)
83
+
84
+
85
+ def get_klines(symbol: str, interval: str = "15m", limit: int = 100) -> list:
86
+ """
87
+ Holt OHLCV-Kerzen für ein Symbol.
88
+
89
+ Args:
90
+ symbol: z.B. "BTCUSDT"
91
+ interval: "15m", "1h", "4h", etc.
92
+ limit: Anzahl Kerzen (max 1000)
93
+
94
+ Returns:
95
+ Liste von [open_time, open, high, low, close, volume, close_time, ...]
96
+ """
97
+ url = f"{config.BINANCE_BASE_URL}/api/v3/klines"
98
+ params = {
99
+ "symbol": symbol,
100
+ "interval": interval,
101
+ "limit": limit
102
+ }
103
+ return _request_with_retry(url, params)
104
+
105
+
106
+ def get_current_price(symbol: str) -> float:
107
+ """
108
+ Gibt den aktuellen Preis eines Symbols zurück.
109
+
110
+ Args:
111
+ symbol: z.B. "BTCUSDT"
112
+
113
+ Returns:
114
+ Aktueller Preis als Float
115
+ """
116
+ url = f"{config.BINANCE_BASE_URL}/api/v3/ticker/price"
117
+ params = {"symbol": symbol}
118
+ data = _request_with_retry(url, params)
119
+ return float(data["price"])
120
+
121
+
122
+ def get_btc_4h_klines(limit: int = 50) -> list:
123
+ """
124
+ Holt BTC 4h-Kerzen für EMA50-Berechnung und 4h-Change.
125
+
126
+ Args:
127
+ limit: Anzahl Kerzen (50 reicht für EMA50 + 4h-Change)
128
+
129
+ Returns:
130
+ Liste von OHLCV-Kerzen
131
+ """
132
+ return get_klines("BTCUSDT", interval="4h", limit=limit)
data/universe.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ universe.py — Top 100 Coins nach 24h Volume filtern.
3
+
4
+ Filterregeln:
5
+ - Nur USDT-Pairs
6
+ - 24h Volume > MIN_LIQUIDITY_USD
7
+ - Keine Stablecoins
8
+ - Keine Leveraged Tokens
9
+ - Sortiert nach Volume absteigend, Top 100
10
+ """
11
+
12
+ import logging
13
+ from dataclasses import dataclass
14
+
15
+ import config
16
+ from data.binance import get_ticker_24hr, BinanceAPIError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class CoinInfo:
23
+ """Info über einen Coin aus dem Universe."""
24
+ symbol: str
25
+ volume_24h_usd: float
26
+ price: float
27
+
28
+
29
+ def _is_stablecoin(symbol: str) -> bool:
30
+ """Prüft ob Symbol ein Stablecoin ist."""
31
+ base = symbol.replace("USDT", "").replace("USDC", "").replace("BUSD", "")
32
+ return base in config.STABLECOINS or symbol in config.STABLECOINS
33
+
34
+
35
+ def _is_leveraged_token(symbol: str) -> bool:
36
+ """Prüft ob Symbol ein Leveraged Token ist."""
37
+ for pattern in config.LEVERAGED_TOKEN_PATTERNS:
38
+ if pattern in symbol.upper():
39
+ return True
40
+ return False
41
+
42
+
43
+ def _is_usdt_pair(symbol: str) -> bool:
44
+ """Prüft ob Symbol ein USDT-Pair ist."""
45
+ return symbol.endswith("USDT")
46
+
47
+
48
+ def get_top100_universe() -> list[CoinInfo]:
49
+ """
50
+ Holt und filtert die Top 100 Coins nach 24h Volume.
51
+
52
+ Returns:
53
+ Liste von CoinInfo, sortiert nach Volume absteigend
54
+
55
+ Raises:
56
+ BinanceAPIError: Bei API-Fehler
57
+ """
58
+ try:
59
+ tickers = get_ticker_24hr()
60
+ except BinanceAPIError as e:
61
+ logger.error(f"Fehler beim Laden der Ticker: {e}")
62
+ raise
63
+
64
+ coins = []
65
+
66
+ for ticker in tickers:
67
+ symbol = ticker["symbol"]
68
+
69
+ # Nur USDT-Pairs
70
+ if not _is_usdt_pair(symbol):
71
+ continue
72
+
73
+ # Keine Stablecoins
74
+ if _is_stablecoin(symbol):
75
+ continue
76
+
77
+ # Keine Leveraged Tokens
78
+ if _is_leveraged_token(symbol):
79
+ continue
80
+
81
+ volume_usd = float(ticker["quoteVolume"])
82
+ price = float(ticker["lastPrice"])
83
+
84
+ # Mindestliquidität
85
+ if volume_usd < config.MIN_LIQUIDITY_USD:
86
+ continue
87
+
88
+ coins.append(CoinInfo(
89
+ symbol=symbol,
90
+ volume_24h_usd=volume_usd,
91
+ price=price
92
+ ))
93
+
94
+ # Sortieren nach Volume absteigend, Top 100
95
+ coins.sort(key=lambda c: c.volume_24h_usd, reverse=True)
96
+ top100 = coins[:config.UNIVERSE_SIZE]
97
+
98
+ logger.info(
99
+ f"Universe: {len(tickers)} Ticker geladen, "
100
+ f"{len(coins)} nach Filterung, Top {len(top100)} ausgewählt"
101
+ )
102
+
103
+ return top100
db/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """db — Supabase Datenbank-Client."""
db/supabase_client.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ supabase_client.py — Alle Datenbank-Operationen.
3
+
4
+ Kapselt alle Supabase-Aufrufe hinter einer einfachen API.
5
+ Alle Methoden haben Fallbacks bei Fehlern.
6
+ """
7
+
8
+ import logging
9
+ from datetime import datetime, timezone
10
+ from typing import Optional
11
+
12
+ import config
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Supabase Client (lazy init)
17
+ _client = None
18
+
19
+
20
+ def _get_client():
21
+ """Gibt Supabase Client (erstellt ihn bei Bedarf)."""
22
+ global _client
23
+ if _client is None:
24
+ try:
25
+ from supabase import create_client
26
+ _client = create_client(config.SUPABASE_URL, config.SUPABASE_KEY)
27
+ except Exception as e:
28
+ logger.error(f"Supabase Client konnte nicht erstellt werden: {e}")
29
+ raise
30
+ return _client
31
+
32
+
33
+ def is_configured() -> bool:
34
+ """Prüft ob Supabase konfiguriert ist."""
35
+ return bool(config.SUPABASE_URL and config.SUPABASE_KEY)
36
+
37
+
38
+ def log_trade(position_data: dict) -> Optional[str]:
39
+ """
40
+ Erstellt einen neuen Trade in der `trades` Tabelle.
41
+
42
+ Args:
43
+ position_data: Dict mit allen Positionsdaten
44
+
45
+ Returns:
46
+ Trade ID oder None bei Fehler
47
+ """
48
+ if not is_configured():
49
+ logger.warning("Supabase nicht konfiguriert — Trade nicht geloggt")
50
+ return None
51
+
52
+ try:
53
+ client = _get_client()
54
+ result = client.table("trades").insert({
55
+ "symbol": position_data["symbol"],
56
+ "entry_price": position_data["entry_price"],
57
+ "position_size_usd": position_data["position_size_usd"],
58
+ "stop_loss": position_data["stop_loss"],
59
+ "take_profit_1": position_data["take_profit_1"],
60
+ "take_profit_2": position_data["take_profit_2"],
61
+ "entry_atr": position_data.get("entry_atr", 0),
62
+ "risk_reward_ratio": position_data.get("risk_reward_ratio", 0),
63
+ "entry_learning_tags": position_data.get("learning_tags", []),
64
+ "status": "open",
65
+ "opened_at": datetime.now(timezone.utc).isoformat(),
66
+ "hold_count": 0
67
+ }).execute()
68
+
69
+ trade_id = result.data[0]["id"] if result.data else None
70
+ logger.info(f"Trade erstellt: {position_data['symbol']} (ID: {trade_id})")
71
+ return trade_id
72
+
73
+ except Exception as e:
74
+ logger.error(f"Fehler beim Erstellen von Trade: {e}")
75
+ return None
76
+
77
+
78
+ def close_trade(
79
+ trade_id: str,
80
+ exit_price: float,
81
+ exit_reason: str,
82
+ exit_tags: list[str] = None,
83
+ pnl_pct: float = 0.0,
84
+ pnl_usd: float = 0.0
85
+ ) -> bool:
86
+ """
87
+ Schließt einen Trade.
88
+
89
+ Args:
90
+ trade_id: ID des Trades
91
+ exit_price: Verkaufspreis
92
+ exit_reason: Grund des Exits
93
+ exit_tags: Learning Tags
94
+ pnl_pct: P&L in Prozent
95
+ pnl_usd: P&L in USD
96
+
97
+ Returns:
98
+ True bei Erfolg
99
+ """
100
+ if not is_configured():
101
+ return False
102
+
103
+ try:
104
+ client = _get_client()
105
+ client.table("trades").update({
106
+ "exit_price": exit_price,
107
+ "exit_reason": exit_reason,
108
+ "exit_learning_tags": exit_tags or [],
109
+ "pnl_pct": pnl_pct,
110
+ "pnl_usd": pnl_usd,
111
+ "status": "closed",
112
+ "closed_at": datetime.now(timezone.utc).isoformat()
113
+ }).eq("id", trade_id).execute()
114
+
115
+ logger.info(f"Trade geschlossen: {trade_id} (Reason: {exit_reason})")
116
+ return True
117
+
118
+ except Exception as e:
119
+ logger.error(f"Fehler beim Schließen von Trade {trade_id}: {e}")
120
+ return False
121
+
122
+
123
+ def get_open_positions() -> list[dict]:
124
+ """
125
+ Gibt alle offenen Positionen zurück.
126
+
127
+ Returns:
128
+ Liste von Trade-Dicts
129
+ """
130
+ if not is_configured():
131
+ return []
132
+
133
+ try:
134
+ client = _get_client()
135
+ result = client.table("trades").select("*").eq("status", "open").execute()
136
+ return result.data
137
+
138
+ except Exception as e:
139
+ logger.error(f"Fehler beim Laden offener Positionen: {e}")
140
+ return []
141
+
142
+
143
+ def get_open_position_count() -> int:
144
+ """
145
+ Anzahl offener Positionen.
146
+
147
+ Returns:
148
+ Anzahl (0 bei Fehler)
149
+ """
150
+ if not is_configured():
151
+ return 0
152
+
153
+ try:
154
+ positions = get_open_positions()
155
+ return len(positions)
156
+
157
+ except Exception as e:
158
+ logger.error(f"Fehler beim Zählen offener Positionen: {e}")
159
+ return 0
160
+
161
+
162
+ def update_position(trade_id: str, updates: dict) -> bool:
163
+ """
164
+ Aktualisiert ein Trade.
165
+
166
+ Args:
167
+ trade_id: ID des Trades
168
+ updates: Dict mit zu aktualisierenden Feldern
169
+
170
+ Returns:
171
+ True bei Erfolg
172
+ """
173
+ if not is_configured():
174
+ return False
175
+
176
+ try:
177
+ client = _get_client()
178
+ client.table("trades").update(updates).eq("id", trade_id).execute()
179
+ return True
180
+
181
+ except Exception as e:
182
+ logger.error(f"Fehler beim Updaten von Trade {trade_id}: {e}")
183
+ return False
184
+
185
+
186
+ def increment_hold_count(trade_id: str, current_count: int) -> bool:
187
+ """
188
+ Erhöht den HOLD-Zähler um 1.
189
+
190
+ Args:
191
+ trade_id: ID des Trades
192
+ current_count: Aktueller HOLD-Count
193
+
194
+ Returns:
195
+ True bei Erfolg
196
+ """
197
+ return update_position(trade_id, {"hold_count": current_count + 1})
198
+
199
+
200
+ def log_scan(scan_result: dict) -> bool:
201
+ """
202
+ Loggt einen Scan-Zyklus.
203
+
204
+ Args:
205
+ scan_result: Dict mit Scan-Ergebnis
206
+
207
+ Returns:
208
+ True bei Erfolg
209
+ """
210
+ if not is_configured():
211
+ return False
212
+
213
+ try:
214
+ client = _get_client()
215
+ client.table("scan_logs").insert({
216
+ "market_gate_passed": scan_result.get("market_gate_passed", False),
217
+ "market_gate_reason": scan_result.get("market_gate_reason", ""),
218
+ "candidates_found": scan_result.get("candidates_found", 0),
219
+ "candidates": scan_result.get("candidates", []),
220
+ "ai_decisions": scan_result.get("ai_decisions", {}),
221
+ "scanned_at": datetime.now(timezone.utc).isoformat()
222
+ }).execute()
223
+
224
+ logger.info("Scan geloggt")
225
+ return True
226
+
227
+ except Exception as e:
228
+ logger.error(f"Fehler beim Loggen des Scans: {e}")
229
+ return False
230
+
231
+
232
+ def log_btc_price(price: float) -> bool:
233
+ """
234
+ Speichert aktuellen BTC-Preis.
235
+
236
+ Args:
237
+ price: Aktueller BTC-Preis
238
+
239
+ Returns:
240
+ True bei Erfolg
241
+ """
242
+ if not is_configured():
243
+ return False
244
+
245
+ try:
246
+ client = _get_client()
247
+ client.table("btc_price_log").insert({
248
+ "price": price,
249
+ "logged_at": datetime.now(timezone.utc).isoformat()
250
+ }).execute()
251
+ return True
252
+
253
+ except Exception as e:
254
+ logger.error(f"Fehler beim Loggen von BTC-Preis: {e}")
255
+ return False
256
+
257
+
258
+ def get_btc_price_minutes_ago(minutes: int = 15) -> Optional[float]:
259
+ """
260
+ Holt BTC-Preis von vor X Minuten.
261
+
262
+ Args:
263
+ minutes: Vergangenheit in Minuten
264
+
265
+ Returns:
266
+ BTC-Preis oder None
267
+ """
268
+ if not is_configured():
269
+ return None
270
+
271
+ try:
272
+ client = _get_client()
273
+ result = client.table("btc_price_log") \
274
+ .select("price") \
275
+ .order("logged_at", desc=True) \
276
+ .limit(10) \
277
+ .execute()
278
+
279
+ if not result.data:
280
+ return None
281
+
282
+ # Nimm den ältesten Eintrag der letzten ~20 Minuten
283
+ # (da wir nur alle 5 Min loggen, ist der 3. Eintrag ca. 15 Min alt)
284
+ index = min(minutes // 5, len(result.data) - 1)
285
+ return result.data[index]["price"]
286
+
287
+ except Exception as e:
288
+ logger.error(f"Fehler beim Laden von BTC-Preis: {e}")
289
+ return None
290
+
291
+
292
+ def is_blacklisted(symbol: str) -> bool:
293
+ """
294
+ Prüft ob ein Coin auf der Blacklist ist.
295
+
296
+ Args:
297
+ symbol: Coin Symbol
298
+
299
+ Returns:
300
+ True wenn auf Blacklist
301
+ """
302
+ if not is_configured():
303
+ return False
304
+
305
+ try:
306
+ client = _get_client()
307
+ result = client.table("blacklist") \
308
+ .select("symbol") \
309
+ .eq("symbol", symbol) \
310
+ .gt("expires_at", datetime.now(timezone.utc).isoformat()) \
311
+ .execute()
312
+
313
+ return len(result.data) > 0
314
+
315
+ except Exception as e:
316
+ logger.error(f"Fehler bei Blacklist-Check für {symbol}: {e}")
317
+ return False
318
+
319
+
320
+ def add_to_blacklist(symbol: str, reason: str, hours: int = None) -> bool:
321
+ """
322
+ Fügt einen Coin zur Blacklist hinzu.
323
+
324
+ Args:
325
+ symbol: Coin Symbol
326
+ reason: Grund
327
+ hours: Dauer in Default (Default: config.COOLDOWN_AFTER_SL_HOURS)
328
+
329
+ Returns:
330
+ True bei Erfolg
331
+ """
332
+ if not is_configured():
333
+ return False
334
+
335
+ if hours is None:
336
+ hours = config.COOLDOWN_AFTER_SL_HOURS
337
+
338
+ try:
339
+ client = _get_client()
340
+ expires_at = datetime.now(timezone.utc).__class__.now(timezone.utc)
341
+ from datetime import timedelta
342
+ expires_at = datetime.now(timezone.utc) + timedelta(hours=hours)
343
+
344
+ client.table("blacklist").upsert({
345
+ "symbol": symbol,
346
+ "reason": reason,
347
+ "expires_at": expires_at.isoformat()
348
+ }).execute()
349
+
350
+ logger.info(f"{symbol} auf Blacklist gesetzt ({hours}h) — Grund: {reason}")
351
+ return True
352
+
353
+ except Exception as e:
354
+ logger.error(f"Fehler beim Setzen von Blacklist für {symbol}: {e}")
355
+ return False
356
+
357
+
358
+ def get_portfolio_value() -> float:
359
+ """
360
+ Berechnet aktuellen Portfolio-Wert (offene Positionen + Startkapital).
361
+
362
+ Returns:
363
+ Portfolio-Wert in USD
364
+ """
365
+ if not is_configured():
366
+ return config.PORTFOLIO_VALUE_USD
367
+
368
+ try:
369
+ positions = get_open_positions()
370
+ total_pnl = sum(p.get("pnl_usd", 0) for p in positions)
371
+ return config.PORTFOLIO_VALUE_USD + total_pnl
372
+
373
+ except Exception as e:
374
+ logger.error(f"Fehler bei Portfolio-Berechnung: {e}")
375
+ return config.PORTFOLIO_VALUE_USD
positions/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """positions — Position Management und Exit-Logik."""
positions/exit_logic.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ exit_logic.py — Komplette Exit-Architektur.
3
+
4
+ Stage 1: Harte Sicherheitsnetze (immer, alle 5 Min, keine KI)
5
+ Stage 2: Sell-Screener (alle 15 Min, nur auf Kerzenabschluss)
6
+ Stage 3: KI Exit-Entscheidung (nur bei Sell-Screener Score 2-3)
7
+ Stage 4: Backend-Validation Exit
8
+ """
9
+
10
+ import logging
11
+ from datetime import datetime, timezone
12
+
13
+ import config
14
+ from data.binance import get_klines, get_current_price, BinanceAPIError
15
+ from screener.indicators import (
16
+ calculate_rsi, calculate_macd, calculate_ema,
17
+ calculate_atr, calculate_volume_ratio
18
+ )
19
+ from ai.exit_prompt import get_exit_decision
20
+ from ai.validator import validate_exit_decision
21
+ from db.supabase_client import (
22
+ get_open_position_count, update_position, get_btc_price_minutes_ago
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ # ──────────────────────────────────────────────
29
+ # Stage 1: Harte Sicherheitsnetze
30
+ # ──────────────────────────────────────────────
31
+
32
+ def check_hard_stops(position: dict, current_price: float) -> tuple[bool, str]:
33
+ """
34
+ Prüft harte Exit-Trigger. Erster Trigger gewinnt.
35
+
36
+ Args:
37
+ position: Position Dict
38
+ current_price: Aktueller Preis
39
+
40
+ Returns:
41
+ Tuple von (exit_triggered, exit_tag)
42
+ """
43
+ symbol = position["symbol"]
44
+ entry_price = position["entry_price"]
45
+ stop_loss = position.get("stop_loss", 0)
46
+ take_profit_1 = position.get("take_profit_1", 0)
47
+ take_profit_2 = position.get("take_profit_2", 0)
48
+ trailing_stop = position.get("trailing_stop")
49
+ trailing_active = position.get("trailing_active", False)
50
+ tp1_hit = position.get("tp1_hit", False)
51
+
52
+ # 1. Harter Stop Loss
53
+ if stop_loss and current_price <= stop_loss:
54
+ logger.info(f"EXIT: Harter Stop Loss für {symbol} (Preis: {current_price} ≤ SL: {stop_loss})")
55
+ return True, "Hit_Hard_Stop"
56
+
57
+ # 2. Take Profit 2 (restliche 50%)
58
+ if tp1_hit and take_profit_2 and current_price >= take_profit_2:
59
+ logger.info(f"EXIT: TP2 erreicht für {symbol}")
60
+ return True, "TP2_Full_Exit"
61
+
62
+ # 3. Take Profit 1 (50% Partial)
63
+ if not tp1_hit and take_profit_1 and current_price >= take_profit_1:
64
+ logger.info(f"EXIT: TP1 erreicht für {symbol}")
65
+ return True, "TP1_Partial_Exit"
66
+
67
+ # 4. Trailing Stop
68
+ if trailing_active and trailing_stop and current_price <= trailing_stop:
69
+ logger.info(f"EXIT: Trailing Stop für {symbol}")
70
+ return True, "Hit_Trailing_Stop"
71
+
72
+ # 5. Time Stop A: > 4h im Minus
73
+ opened_at = position.get("opened_at")
74
+ if opened_at:
75
+ duration_hours = _hours_since(opened_at)
76
+ pnl_pct = ((current_price - entry_price) / entry_price) * 100 if entry_price > 0 else 0
77
+
78
+ if duration_hours > config.TIME_STOP_A_HOURS and pnl_pct < config.TIME_STOP_A_PNL_PCT:
79
+ logger.info(f"EXIT: Time Stop A für {symbol} ({duration_hours:.1f}h, P&L: {pnl_pct:.2f}%)")
80
+ return True, "Time_Stop_Loss"
81
+
82
+ # 6. Time Stop B: > 2h ohne ATR-Bewegung
83
+ if opened_at and not tp1_hit:
84
+ duration_hours = _hours_since(opened_at)
85
+ entry_atr = position.get("entry_atr", 0)
86
+
87
+ if entry_atr > 0 and duration_hours > config.TIME_STOP_B_HOURS:
88
+ target_price = entry_price + entry_atr
89
+ if current_price < target_price and pnl_pct < 0:
90
+ logger.info(f"EXIT: Time Stop B für {symbol} (keine ATR-Bewegung)")
91
+ return True, "Time_Stop_NoMovement"
92
+
93
+ # 7. BTC Crash schnell (15min)
94
+ btc_15min = _get_btc_change_15min()
95
+ if btc_15min is not None and btc_15min < config.BTC_CRASH_FAST_PCT:
96
+ logger.info(f"EXIT: BTC Crash Fast ({btc_15min}%) — alle Positionen")
97
+ return True, "BTC_Crash_Fast"
98
+
99
+ # 8. BTC Crash bestätigt (1h)
100
+ btc_1h = _get_btc_change_1h()
101
+ if btc_1h is not None and btc_1h < config.BTC_CRASH_CONFIRM_PCT:
102
+ logger.info(f"EXIT: BTC Crash Confirmed ({btc_1h}%) — alle Positionen")
103
+ return True, "BTC_Crash_Confirmed"
104
+
105
+ return False, ""
106
+
107
+
108
+ def update_trailing_stop(position: dict, current_price: float) -> dict:
109
+ """
110
+ Aktualisiert den Trailing Stop.
111
+
112
+ Aktivierung: Preis ≥ Entry + (ATR * 1.5)
113
+ Update: Neuer High - (ATR * 1.5), nie nach unten.
114
+
115
+ Args:
116
+ position: Position Dict
117
+ current_price: Aktueller Preis
118
+
119
+ Returns:
120
+ Aktualisiertes Position Dict
121
+ """
122
+ entry = position["entry_price"]
123
+ atr = position.get("entry_atr", 0)
124
+
125
+ if atr <= 0:
126
+ return position
127
+
128
+ activation_level = entry + (atr * config.ATR_TRAILING_ACTIVATION_MULT)
129
+
130
+ # Trailing aktivieren sobald Preis hoch genug
131
+ if not position["trailing_active"] and current_price >= activation_level:
132
+ position["trailing_active"] = True
133
+ position["trailing_stop"] = entry + (atr * 0.5) # Break-Even-Puffer
134
+ position["stop_loss"] = entry * 1.001 # Hard Stop auf Break-Even
135
+ logger.info(f"Trailing aktiviert für {position['symbol']} @ ${current_price:.4f}")
136
+
137
+ # Trailing nachziehen (nie nach unten)
138
+ if position["trailing_active"] and current_price > position.get("highest_price", entry):
139
+ position["highest_price"] = current_price
140
+ new_trailing = current_price - (atr * config.ATR_TRAILING_DISTANCE_MULT)
141
+ position["trailing_stop"] = max(new_trailing, position.get("trailing_stop", 0))
142
+
143
+ return position
144
+
145
+
146
+ # ──────────────────────────────────────────────
147
+ # Stage 2: Sell-Screener
148
+ # ──────────────────────────────────────────────
149
+
150
+ def run_sell_screener(symbol: str, klines: list) -> tuple[int, list[str]]:
151
+ """
152
+ Sell-Screener für ambivalente Signale.
153
+
154
+ Args:
155
+ symbol: Coin Symbol
156
+ klines: OHLCV-Kerzen
157
+
158
+ Returns:
159
+ Tuple von (score, reasons)
160
+ """
161
+ score = 0
162
+ reasons = []
163
+
164
+ if len(klines) < 20:
165
+ return 0, ["TOO_LITTLE_DATA"]
166
+
167
+ closes = [float(k[4]) for k in klines]
168
+ volumes = [float(k[5]) for k in klines]
169
+
170
+ # RSI überkauft (> 78) → 2 Punkte
171
+ rsi = calculate_rsi(closes)
172
+ if rsi > 78:
173
+ score += 2
174
+ reasons.append(f"RSI_Overbought_{rsi:.0f}")
175
+
176
+ # MACD bearish Cross → 2 Punkte
177
+ if len(closes) >= 30:
178
+ macd_history = []
179
+ for i in range(len(closes) - 10, len(closes)):
180
+ subset = closes[:i + 1]
181
+ m = calculate_macd(subset)
182
+ macd_history.append(m)
183
+
184
+ if len(macd_history) >= 2:
185
+ prev = macd_history[-2]
186
+ curr = macd_history[-1]
187
+ if prev["line"] >= prev["signal"] and curr["line"] < curr["signal"]:
188
+ score += 2
189
+ reasons.append("MACD_Bearish_Cross")
190
+
191
+ # Volume-Einbruch → 1 Punkte
192
+ vol_ratio = calculate_volume_ratio(volumes, 20)
193
+ if vol_ratio < 0.5:
194
+ score += 1
195
+ reasons.append(f"Volume_Collapse_{vol_ratio:.1f}x")
196
+
197
+ # Preis unter EMA9 → 1 Punkte
198
+ ema9 = calculate_ema(closes, 9)
199
+ if closes[-1] < ema9:
200
+ score += 1
201
+ reasons.append("Price_Below_EMA9")
202
+
203
+ return score, reasons
204
+
205
+
206
+ # ──────────────────────────────────────────────
207
+ # Stage 3+4: KI Exit + Validation
208
+ # ──────────────────────────────────────────────
209
+
210
+ def process_exit(
211
+ position: dict,
212
+ current_price: float,
213
+ btc_context: dict
214
+ ) -> str:
215
+ """
216
+ Komplette Exit-Pipeline für eine Position.
217
+
218
+ Args:
219
+ position: Position Dict
220
+ current_price: Aktueller Preis
221
+ btc_context: BTC-Kontext
222
+
223
+ Returns:
224
+ "SELL", "HOLD" oder "PARTIAL_SELL"
225
+ """
226
+ symbol = position["symbol"]
227
+
228
+ # Stage 1: Harte Stops
229
+ exit_triggered, exit_tag = check_hard_stops(position, current_price)
230
+ if exit_triggered:
231
+ return "SELL"
232
+
233
+ # Trailing Update
234
+ position = update_trailing_stop(position, current_price)
235
+
236
+ # Stage 2: Sell-Screener (nur bei Kerzenabschluss)
237
+ try:
238
+ klines = get_klines(symbol, interval="15m", limit=100)
239
+ sell_score, sell_reasons = run_sell_screener(symbol, klines)
240
+ except BinanceAPIError:
241
+ return "HOLD" # Bei Fehler: nichts tun
242
+
243
+ logger.info(f"Sell-Screener {symbol}: Score {sell_score} — {', '.join(sell_reasons)}")
244
+
245
+ # Score >= 4 → sofort SELL
246
+ if sell_score >= 4:
247
+ logger.info(f"Sell-Score {sell_score} >= 4 → sofort SELL für {symbol}")
248
+ return "SELL"
249
+
250
+ # Score 2-3 → KI fragen
251
+ if sell_score >= 2:
252
+ return _ask_ki_exit(position, current_price, klines, sell_reasons, btc_context)
253
+
254
+ # Score <= 1 → HOLD
255
+ return "HOLD"
256
+
257
+
258
+ def _ask_ki_exit(
259
+ position: dict,
260
+ current_price: float,
261
+ klines: list,
262
+ sell_reasons: list[str],
263
+ btc_context: dict
264
+ ) -> str:
265
+ """
266
+ Fragt KI für Exit-Entscheidung (Stage 3 + 4).
267
+
268
+ Returns:
269
+ "SELL" oder "HOLD"
270
+ """
271
+ symbol = position["symbol"]
272
+ entry_price = position["entry_price"]
273
+ pnl_pct = ((current_price - entry_price) / entry_price) * 100 if entry_price > 0 else 0
274
+
275
+ # Haltedauer berechnen
276
+ opened_at = position.get("opened_at")
277
+ duration_minutes = _minutes_since(opened_at) if opened_at else 0
278
+
279
+ # Indikatoren berechnen
280
+ closes = [float(k[4]) for k in klines]
281
+ volumes = [float(k[5]) for k in klines]
282
+
283
+ indicators = {
284
+ "rsi": calculate_rsi(closes),
285
+ "macd": calculate_macd(closes),
286
+ "ema9": calculate_ema(closes, 9),
287
+ "volume_ratio_20": calculate_volume_ratio(volumes, 20),
288
+ "atr": calculate_atr(
289
+ [float(k[2]) for k in klines],
290
+ [float(k[3]) for k in klines],
291
+ closes
292
+ )
293
+ }
294
+
295
+ sell_reason = ", ".join(sell_reasons)
296
+
297
+ # KI Exit Decision
298
+ decision = get_exit_decision(
299
+ symbol=symbol,
300
+ entry_price=entry_price,
301
+ current_price=current_price,
302
+ pnl_percent=round(pnl_pct, 2),
303
+ duration_minutes=duration_minutes,
304
+ initial_stop=position.get("stop_loss", 0),
305
+ current_trailing_stop=position.get("trailing_stop"),
306
+ tp1_hit=position.get("tp1_hit", False),
307
+ hold_count=position.get("hold_count", 0),
308
+ ohlcv_data=klines,
309
+ indicators=indicators,
310
+ sell_reason=sell_reason,
311
+ btc_context=btc_context
312
+ )
313
+
314
+ # Stage 4: Backend-Validation
315
+ final_decision = validate_exit_decision(
316
+ decision=decision,
317
+ current_price=current_price,
318
+ current_trailing_stop=position.get("trailing_stop"),
319
+ hold_count=position.get("hold_count", 0)
320
+ )
321
+
322
+ # Trailing Stop Update wenn KI einen vorschlägt
323
+ new_stop = decision.get("new_trailing_stop_usd")
324
+ if new_stop and final_decision == "HOLD":
325
+ position["trailing_stop"] = new_stop
326
+ update_position(position["id"], {"trailing_stop": new_stop})
327
+
328
+ # HOLD Count updaten
329
+ if final_decision == "HOLD":
330
+ increment_hold_count(position["id"], position.get("hold_count", 0))
331
+ else:
332
+ # SELL → HOLD Count reset
333
+ update_position(position["id"], {"hold_count": 0})
334
+
335
+ logger.info(f"KI Exit {symbol}: {final_decision} (KI: {decision.get('decision')})")
336
+ return final_decision
337
+
338
+
339
+ # ──────────────────────────────────────────────
340
+ # Hilfsfunktionen
341
+ # ──────────────────────────────────────────────
342
+
343
+ def _hours_since(timestamp_str: str) -> float:
344
+ """Berechnet Stunden seit einem ISO-Timestamp."""
345
+ try:
346
+ if isinstance(timestamp_str, str):
347
+ dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
348
+ else:
349
+ dt = timestamp_str
350
+ delta = datetime.now(timezone.utc) - dt
351
+ return delta.total_seconds() / 3600
352
+ except Exception:
353
+ return 0.0
354
+
355
+
356
+ def _minutes_since(timestamp_str: str) -> int:
357
+ """Berechnet Minuten seit einem ISO-Timestamp."""
358
+ try:
359
+ if isinstance(timestamp_str, str):
360
+ dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
361
+ else:
362
+ dt = timestamp_str
363
+ delta = datetime.now(timezone.utc) - dt
364
+ return int(delta.total_seconds() / 60)
365
+ except Exception:
366
+ return 0
367
+
368
+
369
+ def _get_btc_change_15min() -> float | None:
370
+ """BTC 15min-Change in %."""
371
+ try:
372
+ current_price = get_current_price("BTCUSDT")
373
+ old_price = get_btc_price_minutes_ago(15)
374
+
375
+ if old_price and old_price > 0:
376
+ return ((current_price - old_price) / old_price) * 100
377
+ except Exception:
378
+ pass
379
+ return None
380
+
381
+
382
+ def _get_btc_change_1h() -> float | None:
383
+ """BTC 1h-Change in %."""
384
+ try:
385
+ current_price = get_current_price("BTCUSDT")
386
+ old_price = get_btc_price_minutes_ago(60)
387
+
388
+ if old_price and old_price > 0:
389
+ return ((current_price - old_price) / old_price) * 100
390
+ except Exception:
391
+ pass
392
+ return None
positions/manager.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ manager.py — Position CRUD-Operationen.
3
+
4
+ Kapselt alle Operationen rund um offene/geschlossene Positionen.
5
+ """
6
+
7
+ import logging
8
+ from datetime import datetime, timezone
9
+
10
+ import config
11
+ from db.supabase_client import (
12
+ log_trade, close_trade, get_open_positions,
13
+ get_open_position_count, update_position,
14
+ increment_hold_count, add_to_blacklist
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def open_position(
21
+ symbol: str,
22
+ entry_price: float,
23
+ position_size_usd: float,
24
+ stop_loss: float,
25
+ take_profit_1: float,
26
+ take_profit_2: float,
27
+ entry_atr: float,
28
+ risk_reward_ratio: float,
29
+ learning_tags: list[str] = None
30
+ ) -> str | None:
31
+ """
32
+ Öffnet eine neue Position (Paper-Trade).
33
+
34
+ Args:
35
+ symbol: Coin Symbol
36
+ entry_price: Einstiegspreis
37
+ position_size_usd: Positionsgröße in USD
38
+ stop_loss: Stop Loss
39
+ take_profit_1: Take Profit 1
40
+ take_profit_2: Take Profit 2
41
+ entry_atr: ATR bei Entry
42
+ risk_reward_ratio: R:R Verhältnis
43
+ learning_tags: Tags für Analyse
44
+
45
+ Returns:
46
+ Trade ID oder None bei Fehler
47
+ """
48
+ # Prüfe ob max Positionen erreicht
49
+ open_count = get_open_position_count()
50
+ if open_count >= config.MAX_OPEN_POSITIONS:
51
+ logger.warning(
52
+ f"Max Positionen erreicht ({open_count}/{config.MAX_OPEN_POSITIONS}). "
53
+ f"{symbol} wird nicht geöffnet."
54
+ )
55
+ return None
56
+
57
+ position_data = {
58
+ "symbol": symbol,
59
+ "entry_price": entry_price,
60
+ "position_size_usd": position_size_usd,
61
+ "stop_loss": stop_loss,
62
+ "take_profit_1": take_profit_1,
63
+ "take_profit_2": take_profit_2,
64
+ "entry_atr": entry_atr,
65
+ "risk_reward_ratio": risk_reward_ratio,
66
+ "learning_tags": learning_tags or []
67
+ }
68
+
69
+ trade_id = log_trade(position_data)
70
+
71
+ if trade_id:
72
+ logger.info(
73
+ f"Position geöffnet: {symbol} @ ${entry_price:.4f} "
74
+ f"(Size: ${position_size_usd:.2f}, SL: ${stop_loss:.4f})"
75
+ )
76
+
77
+ return trade_id
78
+
79
+
80
+ def close_position(
81
+ trade_id: str,
82
+ exit_price: float,
83
+ exit_reason: str,
84
+ exit_tags: list[str] = None,
85
+ symbol: str = ""
86
+ ) -> bool:
87
+ """
88
+ Schließt eine Position komplett.
89
+
90
+ Args:
91
+ trade_id: Trade ID
92
+ exit_price: Verkaufspreis
93
+ exit_reason: Grund
94
+ exit_tags: Learning Tags
95
+ symbol: Symbol (für Blacklist)
96
+
97
+ Returns:
98
+ True bei Erfolg
99
+ """
100
+ from db.supabase_client import get_open_positions
101
+
102
+ # P&L berechnen
103
+ positions = get_open_positions()
104
+ position = next((p for p in positions if p["id"] == trade_id), None)
105
+
106
+ pnl_pct = 0.0
107
+ pnl_usd = 0.0
108
+
109
+ if position:
110
+ entry_price = position["entry_price"]
111
+ size = position["position_size_usd"]
112
+ if entry_price > 0:
113
+ pnl_pct = ((exit_price - entry_price) / entry_price) * 100
114
+ pnl_usd = size * (pnl_pct / 100)
115
+
116
+ success = close_trade(
117
+ trade_id=trade_id,
118
+ exit_price=exit_price,
119
+ exit_reason=exit_reason,
120
+ exit_tags=exit_tags or [],
121
+ pnl_pct=round(pnl_pct, 2),
122
+ pnl_usd=round(pnl_usd, 2)
123
+ )
124
+
125
+ if success:
126
+ logger.info(
127
+ f"Position geschlossen: {symbol} @ ${exit_price:.4f} "
128
+ f"(P&L: {pnl_pct:.2f}%, ${pnl_usd:.2f}) — Grund: {exit_reason}"
129
+ )
130
+
131
+ # Bei Stop Loss → Blacklist
132
+ if "Stop" in exit_reason or "Stop_Loss" in exit_reason:
133
+ if symbol:
134
+ add_to_blacklist(symbol, exit_reason)
135
+
136
+ return success
137
+
138
+
139
+ def close_partial_position(
140
+ trade_id: str,
141
+ exit_price: float,
142
+ exit_reason: str,
143
+ symbol: str = ""
144
+ ) -> bool:
145
+ """
146
+ Schließt 50% der Position (nach TP1).
147
+ Stop wird auf Break-Even gesetzt.
148
+
149
+ Args:
150
+ trade_id: Trade ID
151
+ exit_price: Verkaufspreis
152
+ exit_reason: Grund
153
+ symbol: Symbol
154
+
155
+ Returns:
156
+ True bei Erfolg
157
+ """
158
+ from db.supabase_client import get_open_positions
159
+
160
+ positions = get_open_positions()
161
+ position = next((p for p in positions if p["id"] == trade_id), None)
162
+
163
+ if not position:
164
+ return False
165
+
166
+ entry_price = position["entry_price"]
167
+ size = position["position_size_usd"]
168
+ pnl_pct = ((exit_price - entry_price) / entry_price) * 100 if entry_price > 0 else 0
169
+ pnl_usd = size * (pnl_pct / 100)
170
+
171
+ # 50% verkaufen → position_size halbieren
172
+ new_size = size / 2
173
+
174
+ # Stop auf Break-Even setzen
175
+ updates = {
176
+ "position_size_usd": new_size,
177
+ "stop_loss": entry_price * 1.001, # Break-Even + 0.1%
178
+ "tp1_hit": True,
179
+ "pnl_pct": round(pnl_pct, 2),
180
+ "pnl_usd": round(pnl_usd / 2, 2)
181
+ }
182
+
183
+ success = update_position(trade_id, updates)
184
+
185
+ if success:
186
+ logger.info(
187
+ f"Partial Exit (50%): {symbol} @ ${exit_price:.4f} "
188
+ f"(P&L: {pnl_pct:.2f}%) — Stop auf Break-Even"
189
+ )
190
+
191
+ return success
192
+
193
+
194
+ def get_position_by_id(trade_id: str) -> dict | None:
195
+ """
196
+ Gibt eine Position zurück.
197
+
198
+ Args:
199
+ trade_id: Trade ID
200
+
201
+ Returns:
202
+ Position Dict oder None
203
+ """
204
+ from db.supabase_client import get_open_positions
205
+ positions = get_open_positions()
206
+ return next((p for p in positions if p["id"] == trade_id), None)
207
+
208
+
209
+ def get_all_open_positions() -> list[dict]:
210
+ """
211
+ Gibt alle offenen Positionen zurück.
212
+
213
+ Returns:
214
+ Liste von Positionen
215
+ """
216
+ return get_open_positions()
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ numpy>=1.26
2
+ requests>=2.31
3
+ python-dotenv>=1.0
4
+ supabase>=2.9
5
+ fastapi>=0.115
6
+ uvicorn>=0.30
7
+ anthropic>=0.40
8
+ openai>=1.55
9
+ streamlit>=1.40
10
+ pandas>=2.2
run_ai_test.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ run_ai_test.py — Phase 2 Testscript.
3
+
4
+ Testet KI-Prompts und Validation lokal mit Mocks.
5
+ Kein API-Key nötig für Basis-Tests.
6
+
7
+ Usage:
8
+ python run_ai_test.py
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ from unittest.mock import patch, MagicMock
14
+
15
+ from ai.provider import parse_json_response, AIProviderError
16
+ from ai.entry_prompt import build_entry_prompt, get_entry_decision
17
+ from ai.exit_prompt import build_exit_prompt, get_exit_decision
18
+ from ai.validator import validate_entry_decision, validate_exit_decision, calculate_position_size
19
+
20
+ logging.basicConfig(level=logging.INFO)
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def test_json_parsing():
25
+ """Testet JSON-Extraktion aus verschiedenen KI-Antworten."""
26
+ print("\n[TEST] JSON Parsing")
27
+
28
+ # Fall 1: Direktes JSON
29
+ response1 = '{"decision": "BUY", "confidence": 8}'
30
+ result1 = parse_json_response(response1)
31
+ assert result1["decision"] == "BUY"
32
+ assert result1["confidence"] == 8
33
+ print(" ✓ Direktes JSON")
34
+
35
+ # Fall 2: JSON mit Text davor
36
+ response2 = "Hier ist meine Analyse:\n\n{\"decision\": \"SKIP\", \"confidence\": 5}\n\nFertig."
37
+ result2 = parse_json_response(response2)
38
+ assert result2["decision"] == "SKIP"
39
+ print(" ✓ JSON mit Text davor/danach")
40
+
41
+ # Fall 3: JSON in Code-Block
42
+ response3 = '```json\n{"decision": "BUY", "confidence": 9}\n```'
43
+ result3 = parse_json_response(response3)
44
+ assert result3["decision"] == "BUY"
45
+ print(" ✓ JSON in Code-Block")
46
+
47
+ # Fall 4: Kaprottes JSON → Fehler
48
+ try:
49
+ parse_json_response("Das ist kein JSON")
50
+ assert False, "Sollte Fehler werfen"
51
+ except ValueError:
52
+ print(" ✓ Kaprottes JSON → ValueError")
53
+
54
+
55
+ def test_entry_validation():
56
+ """Testet Entry-Validation."""
57
+ print("\n[TEST] Entry Validation")
58
+
59
+ # Fall 1: Valides BUY
60
+ valid_buy = {
61
+ "decision": "BUY",
62
+ "confidence": 8,
63
+ "entry_price": 100.0,
64
+ "stop_loss": 95.0,
65
+ "take_profit_1": 110.0,
66
+ "position_size_usd": 50.0
67
+ }
68
+ is_valid, reason = validate_entry_decision(valid_buy, 100.0)
69
+ assert is_valid, f"Sollte gültig sein: {reason}"
70
+ print(" ✓ Valides BUY akzeptiert")
71
+
72
+ # Fall 2: Confidence zu niedrig
73
+ low_conf = {**valid_buy, "confidence": 5}
74
+ is_valid, reason = validate_entry_decision(low_conf, 100.0)
75
+ assert not is_valid
76
+ assert "CONFIDENCE" in reason
77
+ print(" ✓ Niedrige Confidence abgelehnt")
78
+
79
+ # Fall 3: Stop über Entry
80
+ bad_sl = {**valid_buy, "stop_loss": 105.0}
81
+ is_valid, reason = validate_entry_decision(bad_sl, 100.0)
82
+ assert not is_valid
83
+ assert "STOP_ABOVE" in reason
84
+ print(" ✓ Stop über Entry abgelehnt")
85
+
86
+ # Fall 4: R:R zu niedrig
87
+ bad_rr = {**valid_buy, "take_profit_1": 102.0}
88
+ is_valid, reason = validate_entry_decision(bad_rr, 100.0)
89
+ assert not is_valid
90
+ assert "RR_TOO_LOW" in reason
91
+ print(" ✓ Niedriges R:R abgelehnt")
92
+
93
+ # Fall 5: Entscheidung ist SKIP
94
+ skip = {**valid_buy, "decision": "SKIP"}
95
+ is_valid, reason = validate_entry_decision(skip, 100.0)
96
+ assert not is_valid
97
+ print(" ✓ SKIP-Entscheidung abgelehnt")
98
+
99
+
100
+ def test_exit_validation():
101
+ """Testet Exit-Validation."""
102
+ print("\n[TEST] Exit Validation")
103
+
104
+ # Fall 1: Valides SELL
105
+ valid_sell = {"decision": "SELL", "confidence": 8}
106
+ result = validate_exit_decision(valid_sell, 100.0, 90.0, 0)
107
+ assert result == "SELL"
108
+ print(" ✓ Valides SELL")
109
+
110
+ # Fall 2: Confidence zu niedrig → SELL
111
+ low_conf = {"decision": "HOLD", "confidence": 3}
112
+ result = validate_exit_decision(low_conf, 100.0, 90.0, 0)
113
+ assert result == "SELL"
114
+ print(" ✓ Niedrige Confidence → SELL")
115
+
116
+ # Fall 3: 3x HOLD → Zwangsverkauf
117
+ hold = {"decision": "HOLD", "confidence": 7}
118
+ result = validate_exit_decision(hold, 100.0, 90.0, 3)
119
+ assert result == "SELL"
120
+ print(" ✓ 3x HOLD → Zwangsverkauf")
121
+
122
+ # Fall 4: Neuer Trailing Stop über Preis → ignoriert
123
+ hold_with_stop = {"decision": "HOLD", "confidence": 7, "new_trailing_stop_usd": 105.0}
124
+ result = validate_exit_decision(hold_with_stop, 100.0, 90.0, 0)
125
+ assert result == "HOLD"
126
+ assert hold_with_stop["new_trailing_stop_usd"] is None
127
+ print(" ✓ Trailing Stop über Preis → ignoriert")
128
+
129
+ # Fall 5: Neuer Trailing Stop schlechter → ignoriert
130
+ hold_with_stop2 = {"decision": "HOLD", "confidence": 7, "new_trailing_stop_usd": 85.0}
131
+ result = validate_exit_decision(hold_with_stop2, 100.0, 90.0, 0)
132
+ assert result == "HOLD"
133
+ assert hold_with_stop2["new_trailing_stop_usd"] is None
134
+ print(" ✓ Trailing Stop schlechter → ignoriert")
135
+
136
+
137
+ def test_position_size():
138
+ """Testet Positionsgrößen-Berechnung."""
139
+ print("\n[TEST] Position Size")
140
+
141
+ # Normalfall
142
+ size = calculate_position_size(100.0, 95.0)
143
+ assert size > 0
144
+ print(f" ✓ Entry=100, SL=95 → Size=${size:.2f}")
145
+
146
+ # SL = Entry → 0
147
+ size = calculate_position_size(100.0, 100.0)
148
+ assert size == 0
149
+ print(" ✓ SL = Entry → Size=0")
150
+
151
+ # SL über Entry → 0
152
+ size = calculate_position_size(100.0, 105.0)
153
+ assert size == 0
154
+ print(" ✓ SL über Entry → Size=0")
155
+
156
+
157
+ def test_entry_prompt_building():
158
+ """Testet Prompt-Building (ohne API-Call)."""
159
+ print("\n[TEST] Entry Prompt Building")
160
+
161
+ system, user = build_entry_prompt(
162
+ symbol="ETHUSDT",
163
+ price=2500.50,
164
+ position_size_usd=50.0,
165
+ ohlcv_data=[[1700000000000, 2500, 2510, 2490, 2505, 1000] for _ in range(10)],
166
+ indicators={
167
+ "rsi": 55.0,
168
+ "macd": {"line": 0.5, "signal": 0.3, "histogram": 0.2},
169
+ "ema9": 2490.0,
170
+ "ema21": 2480.0,
171
+ "ema50": 2450.0,
172
+ "volume_ratio_20": 1.8,
173
+ "volume_ratio_5": 2.1,
174
+ "atr": 15.5
175
+ },
176
+ screener_reason="Volume_Spike_2.1x, MACD_Cross",
177
+ btc_context={"btc_4h_pct": 1.2, "btc_above_ema50": True}
178
+ )
179
+
180
+ assert "ETHUSDT" in user
181
+ assert "2500.50" in user
182
+ assert "Volume_Spike" in user
183
+ assert "JSON" in user
184
+ print(" ✓ Prompt enthält alle Daten")
185
+
186
+ # Test mit Provider Mock
187
+ print("\n[TEST] Entry Decision mit Mock Provider")
188
+
189
+ mock_provider = MagicMock()
190
+ mock_provider.generate.return_value = json.dumps({
191
+ "decision": "BUY",
192
+ "confidence": 8,
193
+ "entry_price": 2500.50,
194
+ "stop_loss": 2470.0,
195
+ "take_profit_1": 2560.0,
196
+ "take_profit_2": 2640.0,
197
+ "risk_reward_ratio": 2.03,
198
+ "learning_tags": ["Volume_Breakout", "MACD_Cross"],
199
+ "reason": "Starker Volume-Breakout mit MACD-Bestätigung."
200
+ })
201
+
202
+ with patch("ai.entry_prompt.get_provider", return_value=mock_provider):
203
+ decision = get_entry_decision(
204
+ symbol="ETHUSDT",
205
+ price=2500.50,
206
+ position_size_usd=50.0,
207
+ ohlcv_data=[],
208
+ indicators={},
209
+ screener_reason="Volume_Spike",
210
+ btc_context={}
211
+ )
212
+
213
+ assert decision["decision"] == "BUY"
214
+ assert decision["confidence"] == 8
215
+ print(f" ✓ Mock Decision: {decision['decision']} (Confidence: {decision['confidence']})")
216
+
217
+
218
+ def main():
219
+ print("=" * 60)
220
+ print(" TRADING BOT — Phase 2 KI Test")
221
+ print("=" * 60)
222
+
223
+ test_json_parsing()
224
+ test_entry_validation()
225
+ test_exit_validation()
226
+ test_position_size()
227
+ test_entry_prompt_building()
228
+
229
+ print("\n" + "=" * 60)
230
+ print(" ALLE TESTS BESTANDEN ✓")
231
+ print("=" * 60)
232
+
233
+
234
+ if __name__ == "__main__":
235
+ main()
run_db_test.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ run_db_test.py — Phase 3 Testscript.
3
+
4
+ Testet Supabase-Anbindung und DB-Operationen.
5
+ Braucht SUPABASE_URL und SUPABASE_KEY in .env.
6
+
7
+ Usage:
8
+ python run_db_test.py
9
+ """
10
+
11
+ import logging
12
+ from datetime import datetime, timezone
13
+ from dotenv import load_dotenv
14
+
15
+ load_dotenv()
16
+
17
+ from db.supabase_client import (
18
+ log_trade, close_trade, get_open_positions, get_open_position_count,
19
+ update_position, log_scan, log_btc_price, get_btc_price_minutes_ago,
20
+ is_blacklisted, add_to_blacklist, get_portfolio_value, is_configured
21
+ )
22
+
23
+ logging.basicConfig(level=logging.INFO)
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def test_connection():
28
+ """Testet Supabase-Verbindung."""
29
+ print("\n[TEST] Supabase Verbindung")
30
+
31
+ if not is_configured():
32
+ print(" ✗ Supabase nicht konfiguriert!")
33
+ print(" → Setze SUPABASE_URL und SUPABASE_KEY in .env")
34
+ return False
35
+
36
+ print(" ✓ Supabase konfiguriert")
37
+ return True
38
+
39
+
40
+ def test_trade_logging():
41
+ """Testet Trade-Erstellung und -Schließung."""
42
+ print("\n[TEST] Trade Logging")
43
+
44
+ # Trade erstellen
45
+ trade_data = {
46
+ "symbol": "TESTUSDT",
47
+ "entry_price": 100.0,
48
+ "position_size_usd": 50.0,
49
+ "stop_loss": 95.0,
50
+ "take_profit_1": 110.0,
51
+ "take_profit_2": 120.0,
52
+ "entry_atr": 2.5,
53
+ "risk_reward_ratio": 2.0,
54
+ "learning_tags": ["Volume_Breakout", "MACD_Cross"]
55
+ }
56
+
57
+ trade_id = log_trade(trade_data)
58
+ if not trade_id:
59
+ print(" ✗ Trade konnte nicht erstellt werden")
60
+ return False
61
+
62
+ print(f" ✓ Trade erstellt: {trade_id}")
63
+
64
+ # Trade schließen
65
+ success = close_trade(
66
+ trade_id=trade_id,
67
+ exit_price=110.0,
68
+ exit_reason="TP1_Partial_Exit",
69
+ exit_tags=["Take_Profit"],
70
+ pnl_pct=10.0,
71
+ pnl_usd=5.0
72
+ )
73
+ print(f" ✓ Trade geschlossen: {success}")
74
+
75
+ return True
76
+
77
+
78
+ def test_btc_price_log():
79
+ """Testet BTC-Preis-Logging."""
80
+ print("\n[TEST] BTC Preis Log")
81
+
82
+ success = log_btc_price(67500.0)
83
+ print(f" ✓ BTC-Preis geloggt: {success}")
84
+
85
+ price = get_btc_price_minutes_ago(15)
86
+ print(f" ✓ BTC-Preis vor 15min: {price}")
87
+
88
+ return True
89
+
90
+
91
+ def test_scan_log():
92
+ """Testet Scan-Logging."""
93
+ print("\n[TEST] Scan Log")
94
+
95
+ success = log_scan({
96
+ "market_gate_passed": True,
97
+ "market_gate_reason": "OK",
98
+ "candidates_found": 2,
99
+ "candidates": ["ETHUSDT", "SOLUSDT"],
100
+ "ai_decisions": {"ETHUSDT": "BUY", "SOLUSDT": "SKIP"}
101
+ })
102
+ print(f" ✓ Scan geloggt: {success}")
103
+
104
+ return True
105
+
106
+
107
+ def test_blacklist():
108
+ """Testet Blacklist-Funktion."""
109
+ print("\n[TEST] Blacklist")
110
+
111
+ # Hinzufügen
112
+ success = add_to_blacklist("TESTUSDT", "Stop Loss Hit", hours=1)
113
+ print(f" ✓ Zur Blacklist hinzugefügt: {success}")
114
+
115
+ # Prüfen
116
+ blocked = is_blacklisted("TESTUSDT")
117
+ print(f" ✓ Auf Blacklist: {blocked}")
118
+
119
+ # Nicht auf Blacklist
120
+ not_blocked = is_blacklisted("ETHUSDT")
121
+ print(f" ✓ Nicht auf Blacklist (ETHUSDT): {not_blocked}")
122
+
123
+ return True
124
+
125
+
126
+ def test_portfolio():
127
+ """Testet Portfolio-Berechnung."""
128
+ print("\n[TEST] Portfolio")
129
+
130
+ value = get_portfolio_value()
131
+ print(f" ✓ Portfolio-Wert: ${value:.2f}")
132
+
133
+ return True
134
+
135
+
136
+ def main():
137
+ print("=" * 60)
138
+ print(" TRADING BOT — Phase 3 Supabase Test")
139
+ print("=" * 60)
140
+
141
+ if not test_connection():
142
+ print("\n ✗ Abgebrochen — Supabase nicht konfiguriert")
143
+ print(" Erstelle eine .env Datei mit SUPABASE_URL und SUPABASE_KEY")
144
+ return
145
+
146
+ test_trade_logging()
147
+ test_btc_price_log()
148
+ test_scan_log()
149
+ test_blacklist()
150
+ test_portfolio()
151
+
152
+ print("\n" + "=" * 60)
153
+ print(" TESTS ABGESCHLOSSEN")
154
+ print("=" * 60)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
run_exit_test.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ run_exit_test.py — Phase 4 Testscript.
3
+
4
+ Testet Exit-Logik mit Mock-Daten.
5
+ Kein API-Key nötig.
6
+
7
+ Usage:
8
+ python run_exit_test.py
9
+ """
10
+
11
+ import logging
12
+ from datetime import datetime, timezone, timedelta
13
+ from unittest.mock import patch, MagicMock
14
+
15
+ from positions.exit_logic import (
16
+ check_hard_stops,
17
+ update_trailing_stop,
18
+ run_sell_screener,
19
+ process_exit
20
+ )
21
+
22
+ logging.basicConfig(level=logging.INFO)
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def test_hard_stop_loss():
27
+ """Testet Harter Stop Loss."""
28
+ print("\n[TEST] Harter Stop Loss")
29
+
30
+ position = {
31
+ "symbol": "TESTUSDT",
32
+ "entry_price": 100.0,
33
+ "stop_loss": 95.0,
34
+ "take_profit_1": 110.0,
35
+ "take_profit_2": 120.0,
36
+ "trailing_stop": None,
37
+ "trailing_active": False,
38
+ "tp1_hit": False,
39
+ "opened_at": datetime.now(timezone.utc).isoformat()
40
+ }
41
+
42
+ # Preis unter SL
43
+ triggered, tag = check_hard_stops(position, 94.0)
44
+ assert triggered
45
+ assert tag == "Hit_Hard_Stop"
46
+ print(" ✓ Harter SL triggered bei 94 (SL=95)")
47
+
48
+ # Preis über SL
49
+ triggered, tag = check_hard_stops(position, 96.0)
50
+ assert not triggered
51
+ print(" ✓ Kein SL bei 96 (SL=95)")
52
+
53
+
54
+ def test_take_profit():
55
+ """Testet Take Profit 1 und 2."""
56
+ print("\n[TEST] Take Profit")
57
+
58
+ position = {
59
+ "symbol": "TESTUSDT",
60
+ "entry_price": 100.0,
61
+ "stop_loss": 95.0,
62
+ "take_profit_1": 110.0,
63
+ "take_profit_2": 120.0,
64
+ "trailing_stop": None,
65
+ "trailing_active": False,
66
+ "tp1_hit": False,
67
+ "opened_at": datetime.now(timezone.utc).isoformat()
68
+ }
69
+
70
+ # TP1 erreicht
71
+ triggered, tag = check_hard_stops(position, 111.0)
72
+ assert triggered
73
+ assert tag == "TP1_Partial_Exit"
74
+ print(" ✓ TP1 triggered bei 111 (TP1=110)")
75
+
76
+ # TP2 erreicht (tp1_hit=True)
77
+ position["tp1_hit"] = True
78
+ triggered, tag = check_hard_stops(position, 121.0)
79
+ assert triggered
80
+ assert tag == "TP2_Full_Exit"
81
+ print(" ✓ TP2 triggered bei 121 (TP2=120)")
82
+
83
+
84
+ def test_trailing_stop():
85
+ """Testet Trailing Stop."""
86
+ print("\n[TEST] Trailing Stop")
87
+
88
+ position = {
89
+ "symbol": "TESTUSDT",
90
+ "entry_price": 100.0,
91
+ "entry_atr": 2.0,
92
+ "stop_loss": 95.0,
93
+ "take_profit_1": 110.0,
94
+ "take_profit_2": 120.0,
95
+ "trailing_stop": None,
96
+ "trailing_active": False,
97
+ "tp1_hit": False,
98
+ "opened_at": datetime.now(timezone.utc).isoformat()
99
+ }
100
+
101
+ # Preis unter Activation Level (Entry + ATR*1.5 = 103)
102
+ position = update_trailing_stop(position, 102.0)
103
+ assert not position["trailing_active"]
104
+ print(" ✓ Trailing nicht aktiv bei 102 (Activation: 103)")
105
+
106
+ # Preis über Activation
107
+ position = update_trailing_stop(position, 105.0)
108
+ assert position["trailing_active"]
109
+ assert position["trailing_stop"] == 101.0 # Entry + ATR*0.5
110
+ print(f" ✓ Trailing aktiv bei 105, Stop: {position['trailing_stop']}")
111
+
112
+ # Preis steigt → Trailing nachziehen
113
+ position = update_trailing_stop(position, 110.0)
114
+ expected_stop = 110.0 - (2.0 * 1.5) # 107.0
115
+ assert position["trailing_stop"] == expected_stop
116
+ print(f" ✓ Trailing gezogen auf {position['trailing_stop']} bei Preis 110")
117
+
118
+ # Preis fällt unter Trailing
119
+ triggered, tag = check_hard_stops(position, 106.0)
120
+ assert triggered
121
+ assert tag == "Hit_Trailing_Stop"
122
+ print(" ✓ Trailing Stop triggered bei 106")
123
+
124
+
125
+ def test_time_stops():
126
+ """Testet Time Stops."""
127
+ print("\n[TEST] Time Stops")
128
+
129
+ # Time Stop A: > 4h im Minus
130
+ old_time = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat()
131
+ position = {
132
+ "symbol": "TESTUSDT",
133
+ "entry_price": 100.0,
134
+ "stop_loss": 50.0, # Weit unten
135
+ "take_profit_1": 200.0, # Weit oben
136
+ "take_profit_2": 300.0,
137
+ "trailing_stop": None,
138
+ "trailing_active": False,
139
+ "tp1_hit": False,
140
+ "opened_at": old_time,
141
+ "entry_atr": 2.0
142
+ }
143
+
144
+ triggered, tag = check_hard_stops(position, 99.0) # -1%
145
+ assert triggered
146
+ assert tag == "Time_Stop_Loss"
147
+ print(" ✓ Time Stop A nach 5h bei -1%")
148
+
149
+ # Time Stop B: > 2h ohne ATR-Bewegung
150
+ old_time = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
151
+ position["opened_at"] = old_time
152
+ position["stop_loss"] = 50.0
153
+ position["take_profit_1"] = 200.0
154
+
155
+ triggered, tag = check_hard_stops(position, 99.0) # Nie Entry + ATR (102) erreicht
156
+ assert triggered
157
+ assert tag == "Time_Stop_NoMovement"
158
+ print(" ✓ Time Stop B nach 3h ohne ATR-Bewegung")
159
+
160
+
161
+ def test_sell_screener():
162
+ """Testet Sell-Screener."""
163
+ print("\n[TEST] Sell-Screener")
164
+
165
+ # Simuliere Kerzen mit RSI > 78
166
+ base_klines = []
167
+ price = 100.0
168
+ for i in range(100):
169
+ # Steigender Preis → RSI hoch
170
+ price += 0.1
171
+ base_klines.append([0, price, price + 1, price - 1, price, 1000])
172
+
173
+ score, reasons = run_sell_screener("TESTUSDT", base_klines)
174
+ print(f" ✓ Sell-Score: {score}, Reasons: {reasons}")
175
+ # Score sollte > 0 sein wegen steigendem Preis
176
+
177
+
178
+ def test_trailing_never_moves_down():
179
+ """Testet dass Trailing Stop nie nach unten geht."""
180
+ print("\n[TEST] Trailing Stop nie nach unten")
181
+
182
+ position = {
183
+ "symbol": "TESTUSDT",
184
+ "entry_price": 100.0,
185
+ "entry_atr": 2.0,
186
+ "stop_loss": 95.0,
187
+ "take_profit_1": 110.0,
188
+ "take_profit_2": 120.0,
189
+ "trailing_stop": 105.0,
190
+ "trailing_active": True,
191
+ "tp1_hit": False,
192
+ "opened_at": datetime.now(timezone.utc).isoformat()
193
+ }
194
+
195
+ # Preis fällt → Trailing bleibt bei 105
196
+ position = update_trailing_stop(position, 103.0)
197
+ assert position["trailing_stop"] == 105.0
198
+ print(" ✓ Trailing bleibt bei 105 bei fallendem Preis")
199
+
200
+ # Preis steigt über vorheriges High
201
+ position = update_trailing_stop(position, 115.0)
202
+ assert position["trailing_stop"] > 105.0
203
+ print(f" ✓ Trailing gezogen auf {position['trailing_stop']}")
204
+
205
+
206
+ def main():
207
+ print("=" * 60)
208
+ print(" TRADING BOT — Phase 4 Exit-Logik Test")
209
+ print("=" * 60)
210
+
211
+ test_hard_stop_loss()
212
+ test_take_profit()
213
+ test_trailing_stop()
214
+ test_time_stops()
215
+ test_sell_screener()
216
+ test_trailing_never_moves_down()
217
+
218
+ print("\n" + "=" * 60)
219
+ print(" ALLE TESTS BESTANDEN ✓")
220
+ print("=" * 60)
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()
run_full_test.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ run_full_test.py — Komplett-Test fuer alle Phasen.
3
+
4
+ Testet:
5
+ 1. Market Gate (BTC-Filter)
6
+ 2. Universe + Screener
7
+ 3. KI Entry Decision
8
+ 4. DB-Operationen
9
+ 5. Exit-Logik
10
+ """
11
+
12
+ import logging
13
+ import time
14
+ import json
15
+ from datetime import datetime
16
+
17
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def test_market_gate():
22
+ """Test Stage 0: Market Gate."""
23
+ print("\n=== STAGE 0: MARKET GATE ===")
24
+ from screener.market_gate import market_gate_check
25
+
26
+ result = market_gate_check()
27
+ print(f"Passed: {result.passed}")
28
+ print(f"Reason: {result.reason}")
29
+ print(f"BTC 4h: {result.btc_4h_change_pct}%")
30
+ print(f"BTC > EMA50: {result.btc_above_ema50}")
31
+ return result.passed
32
+
33
+
34
+ def test_screener():
35
+ """Test Stage 1: Screener."""
36
+ print("\n=== STAGE 1: SCREENER ===")
37
+ from data.universe import get_top100_universe
38
+ from screener.screener import run_screener
39
+
40
+ universe = get_top100_universe()
41
+ print(f"Universe: {len(universe)} Coins")
42
+
43
+ start = time.time()
44
+ candidates = run_screener(universe)
45
+ duration = time.time() - start
46
+
47
+ print(f"Screener: {duration:.1f}s")
48
+ print(f"Kandidaten: {len(candidates)}")
49
+
50
+ for c in candidates:
51
+ print(f" {c.symbol}: Score={c.score}, Reasons={', '.join(c.reasons)}")
52
+
53
+ return candidates
54
+
55
+
56
+ def test_ki_entry():
57
+ """Test Stage 2: KI Entry."""
58
+ print("\n=== STAGE 2: KI ENTRY ===")
59
+ from ai.provider import get_provider, parse_json_response
60
+ from ai.validator import validate_entry_decision, calculate_position_size
61
+
62
+ provider = get_provider()
63
+ print(f"Provider: {type(provider).__name__}")
64
+ print(f"Model: {provider.model}")
65
+
66
+ # Test mit einfachem Prompt
67
+ system = "Du bist ein Trading-Bot. Antworte nur mit JSON."
68
+ user = 'ASSET: ETHUSDT PREIS: 3500. Antworte als JSON: {"decision": "SKIP", "confidence": 5, "reason": "Test"}'
69
+
70
+ response = provider.generate(system, user)
71
+ print(f"KI-Antwort: {response[:100]}")
72
+
73
+ # Test Validator
74
+ test_decision = {
75
+ "decision": "BUY",
76
+ "confidence": 8,
77
+ "entry_price": 100.0,
78
+ "stop_loss": 95.0,
79
+ "take_profit_1": 110.0,
80
+ "position_size_usd": 50.0
81
+ }
82
+
83
+ is_valid, reason = validate_entry_decision(test_decision, 100.0)
84
+ print(f"Validator (gueltiges BUY): {is_valid} - {reason}")
85
+
86
+ # Position Size
87
+ size = calculate_position_size(100.0, 95.0)
88
+ print(f"Position Size: ${size:.2f}")
89
+
90
+
91
+ def test_db():
92
+ """Test Stage 3: DB."""
93
+ print("\n=== STAGE 3: DATABASE ===")
94
+ from db.supabase_client import (
95
+ is_configured, log_btc_price, log_scan,
96
+ get_open_position_count, get_portfolio_value
97
+ )
98
+
99
+ print(f"DB konfiguriert: {is_configured()}")
100
+
101
+ # BTC Preis loggen
102
+ success = log_btc_price(62534.92)
103
+ print(f"BTC Preis geloggt: {success}")
104
+
105
+ # Scan loggen
106
+ scan_success = log_scan({
107
+ "market_gate_passed": True,
108
+ "market_gate_reason": "OK",
109
+ "candidates_found": 0,
110
+ "candidates": [],
111
+ "ai_decisions": {}
112
+ })
113
+ print(f"Scan geloggt: {scan_success}")
114
+
115
+ # Portfolio
116
+ value = get_portfolio_value()
117
+ print(f"Portfolio-Wert: ${value:.2f}")
118
+
119
+ count = get_open_position_count()
120
+ print(f"Offene Positionen: {count}")
121
+
122
+
123
+ def test_exit_logic():
124
+ """Test Stage 4: Exit."""
125
+ print("\n=== STAGE 4: EXIT LOGIC ===")
126
+ from positions.exit_logic import check_hard_stops, update_trailing_stop
127
+
128
+ # Test Harter Stop Loss
129
+ position = {
130
+ "symbol": "TESTUSDT",
131
+ "entry_price": 100.0,
132
+ "stop_loss": 95.0,
133
+ "take_profit_1": 110.0,
134
+ "take_profit_2": 120.0,
135
+ "trailing_stop": None,
136
+ "trailing_active": False,
137
+ "tp1_hit": False,
138
+ "opened_at": datetime.now().isoformat()
139
+ }
140
+
141
+ triggered, tag = check_hard_stops(position, 94.0)
142
+ print(f"Harter SL (Preis=94, SL=95): triggered={triggered}, tag={tag}")
143
+
144
+ # Test Trailing
145
+ position["entry_atr"] = 2.0
146
+ position = update_trailing_stop(position, 105.0)
147
+ print(f"Trailing aktiv: {position['trailing_active']}")
148
+ if position["trailing_active"]:
149
+ print(f"Trailing Stop: {position['trailing_stop']}")
150
+
151
+
152
+ def main():
153
+ print("=" * 60)
154
+ print(" TRADING BOT -- Komplett Test")
155
+ print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
156
+ print("=" * 60)
157
+
158
+ gate_passed = test_market_gate()
159
+
160
+ if gate_passed:
161
+ test_screener()
162
+ else:
163
+ print("\n[Ubersprungen] Screener (Market Gate blockiert)")
164
+
165
+ test_ki_entry()
166
+ test_db()
167
+ test_exit_logic()
168
+
169
+ print("\n" + "=" * 60)
170
+ print(" ALLE TESTS ABGESCHLOSSEN")
171
+ print("=" * 60)
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()
run_screener_test.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ run_screener_test.py — Phase 1 Testscript.
3
+
4
+ Startet den kompletten Screener lokal:
5
+ 1. Market Gate (BTC-Filter)
6
+ 2. Universe laden (Top 100)
7
+ 3. Screener über alle Coins
8
+ 4. Top 5 Kandidaten im Terminal
9
+
10
+ Usage:
11
+ python run_screener_test.py
12
+ """
13
+
14
+ import time
15
+ import logging
16
+ from datetime import datetime
17
+
18
+ from screener.market_gate import market_gate_check
19
+ from data.universe import get_top100_universe
20
+ from screener.screener import run_screener
21
+
22
+ # Logging konfigurieren
23
+ logging.basicConfig(
24
+ level=logging.INFO,
25
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
26
+ datefmt="%H:%M:%S"
27
+ )
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def main():
32
+ print("=" * 60)
33
+ print(f" TRADING BOT -- Phase 1 Screener Test")
34
+ print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
35
+ print("=" * 60)
36
+ print()
37
+
38
+ start_time = time.time()
39
+
40
+ # ── Stage 0: Market Gate ──
41
+ print("[1/3] Market Gate (BTC-Filter)...")
42
+ gate_result = market_gate_check()
43
+
44
+ if not gate_result.passed:
45
+ print(f" [BLOCKED] {gate_result.reason}")
46
+ print(f" -> Kein Screener, kein Trade.")
47
+ return
48
+
49
+ print(f" [PASSED] Market Gate OK")
50
+ print(f" BTC 4h Change: {gate_result.btc_4h_change_pct}%")
51
+ print(f" BTC ueber EMA50: {gate_result.btc_above_ema50}")
52
+ print()
53
+
54
+ # ── Universe laden ──
55
+ print("[2/3] Lade Top 100 Universe von Binance...")
56
+ universe = get_top100_universe()
57
+ print(f" [OK] {len(universe)} Coins geladen")
58
+ if universe:
59
+ print(f" Top 5 nach Volume:")
60
+ for i, coin in enumerate(universe[:5]):
61
+ vol_m = coin.volume_24h_usd / 1_000_000
62
+ print(f" {i + 1}. {coin.symbol}: ${vol_m:.0f}M Vol")
63
+ print()
64
+
65
+ # ── Stage 1: Screener ──
66
+ print("[3/3] Screener laeuft (Top 100 -> Kandidaten)...")
67
+ screener_start = time.time()
68
+ candidates = run_screener(universe)
69
+ screener_duration = time.time() - screener_start
70
+
71
+ print(f" [OK] Screener in {screener_duration:.1f}s abgeschlossen")
72
+ print()
73
+
74
+ # ── Ergebnisse ──
75
+ print("=" * 60)
76
+ print(f" KANDIDATEN ({len(candidates)} gefunden)")
77
+ print("=" * 60)
78
+
79
+ if not candidates:
80
+ print(" Keine Kandidaten gefunden. Markt ist ruhig.")
81
+ else:
82
+ for i, c in enumerate(candidates):
83
+ print(f"\n #{i + 1} {c.symbol}")
84
+ print(f" Score: {c.score}/10")
85
+ print(f" Preis: ${c.price:.6f}")
86
+ print(f" ATR: ${c.atr:.6f}")
87
+ print(f" Gruende: {', '.join(c.reasons)}")
88
+
89
+ total_duration = time.time() - start_time
90
+ print()
91
+ print("=" * 60)
92
+ print(f" GESAMT: {total_duration:.1f}s")
93
+ print("=" * 60)
94
+
95
+ if total_duration > 5.0:
96
+ print(" [WARNUNG] Laenger als 5 Sekunden! Performance optimieren.")
97
+ else:
98
+ print(" [OK] Performance < 5s")
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
schema.sql ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Trading Bot — Supabase SQL Schema
2
+ -- Füge diese Datei im Supabase SQL Editor aus
3
+
4
+ -- ──────────────────────────────────────────────
5
+ -- Tabelle: trades
6
+ -- ──────────────────────────────────────────────
7
+ CREATE TABLE IF NOT EXISTS trades (
8
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
9
+ symbol TEXT NOT NULL,
10
+ entry_price FLOAT NOT NULL,
11
+ exit_price FLOAT,
12
+ position_size_usd FLOAT NOT NULL,
13
+ stop_loss FLOAT NOT NULL,
14
+ take_profit_1 FLOAT NOT NULL,
15
+ take_profit_2 FLOAT NOT NULL,
16
+ entry_atr FLOAT NOT NULL DEFAULT 0,
17
+ pnl_pct FLOAT,
18
+ pnl_usd FLOAT,
19
+ risk_reward_ratio FLOAT,
20
+ entry_learning_tags TEXT[],
21
+ exit_learning_tags TEXT[],
22
+ exit_reason TEXT,
23
+ opened_at TIMESTAMPTZ DEFAULT NOW(),
24
+ closed_at TIMESTAMPTZ,
25
+ status TEXT DEFAULT 'open', -- 'open', 'closed'
26
+ hold_count INT DEFAULT 0
27
+ );
28
+
29
+ -- ──────────────────────────────────────────────
30
+ -- Tabelle: scan_logs
31
+ -- ──────────────────────────────────────────────
32
+ CREATE TABLE IF NOT EXISTS scan_logs (
33
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
34
+ scanned_at TIMESTAMPTZ DEFAULT NOW(),
35
+ market_gate_passed BOOLEAN NOT NULL,
36
+ market_gate_reason TEXT,
37
+ candidates_found INT DEFAULT 0,
38
+ candidates TEXT[],
39
+ ai_decisions JSONB
40
+ );
41
+
42
+ -- ──────────────────────────────────────────────
43
+ -- Tabelle: btc_price_log
44
+ -- ──────────────────────────────────────────────
45
+ CREATE TABLE IF NOT EXISTS btc_price_log (
46
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
47
+ price FLOAT NOT NULL,
48
+ logged_at TIMESTAMPTZ DEFAULT NOW()
49
+ );
50
+
51
+ -- Index für schnellere Abfragen
52
+ CREATE INDEX IF NOT EXISTS idx_btc_price_log_logged_at
53
+ ON btc_price_log (logged_at DESC);
54
+
55
+ -- ──────────────────────────────────────────────
56
+ -- Tabelle: blacklist
57
+ -- ──────────────────────────────────────────────
58
+ CREATE TABLE IF NOT EXISTS blacklist (
59
+ symbol TEXT PRIMARY KEY,
60
+ reason TEXT,
61
+ expires_at TIMESTAMPTZ NOT NULL
62
+ );
63
+
64
+ -- Index für schnelle Blacklist-Checks
65
+ CREATE INDEX IF NOT EXISTS idx_blacklist_expires
66
+ ON blacklist (expires_at);
67
+
68
+ -- ──────────────────────────────────────────────
69
+ -- Row Level Security deaktivieren (für Bot)
70
+ -- ──────────────────────────────────────────────
71
+ ALTER TABLE trades DISABLE ROW LEVEL SECURITY;
72
+ ALTER TABLE scan_logs DISABLE ROW LEVEL SECURITY;
73
+ ALTER TABLE btc_price_log DISABLE ROW LEVEL SECURITY;
74
+ ALTER TABLE blacklist DISABLE ROW LEVEL SECURITY;
screener/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """screener — Markt-Screener und Indikatoren-Berechnung."""
screener/indicators.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ indicators.py — Technische Indikatoren berechnen.
3
+
4
+ Alle Berechnungen nutzen nur numpy — keine externen Trading-Bibliotheken.
5
+ Das ist wichtig für den Hugging Face Free Tier (wenig Speicher).
6
+ """
7
+
8
+ import numpy as np
9
+
10
+
11
+ def calculate_rsi(closes: list[float], period: int = 14) -> float:
12
+ """
13
+ Wilder RSI (Relative Strength Index).
14
+
15
+ Args:
16
+ closes: Liste der Schlusskurse (neueste am Ende)
17
+ period: RSI-Periode (Standard: 14)
18
+
19
+ Returns:
20
+ RSI Wert zwischen 0 und 100
21
+ """
22
+ if len(closes) < period + 1:
23
+ return 50.0 # Neutral wenn nicht genug Daten
24
+
25
+ arr = np.array(closes, dtype=np.float64)
26
+ deltas = np.diff(arr)
27
+
28
+ # Gewinne und Verluste trennen
29
+ gains = np.where(deltas > 0, deltas, 0.0)
30
+ losses = np.where(deltas < 0, -deltas, 0.0)
31
+
32
+ # Erste einfache Mittelung
33
+ avg_gain = np.mean(gains[:period])
34
+ avg_loss = np.mean(losses[:period])
35
+
36
+ # Wilder's Smoothing (exponentiell)
37
+ for i in range(period, len(gains)):
38
+ avg_gain = (avg_gain * (period - 1) + gains[i]) / period
39
+ avg_loss = (avg_loss * (period - 1) + losses[i]) / period
40
+
41
+ if avg_loss == 0:
42
+ return 100.0
43
+
44
+ rs = avg_gain / avg_loss
45
+ rsi = 100.0 - (100.0 / (1.0 + rs))
46
+
47
+ return round(rsi, 2)
48
+
49
+
50
+ def calculate_ema(data: list[float], period: int) -> float:
51
+ """
52
+ Exponential Moving Average (EMA).
53
+
54
+ Args:
55
+ data: Liste der Werte (neueste am Ende)
56
+ period: EMA-Periode
57
+
58
+ Returns:
59
+ Aktueller EMA Wert
60
+ """
61
+ if len(data) < period:
62
+ return data[-1] if data else 0.0
63
+
64
+ arr = np.array(data, dtype=np.float64)
65
+ multiplier = 2.0 / (period + 1)
66
+
67
+ # EMA mit Startwert = SMA der ersten `period` Werte
68
+ ema = np.mean(arr[:period])
69
+
70
+ for value in arr[period:]:
71
+ ema = (value - ema) * multiplier + ema
72
+
73
+ return round(float(ema), 8)
74
+
75
+
76
+ def calculate_macd(
77
+ closes: list[float],
78
+ fast: int = 12,
79
+ slow: int = 26,
80
+ signal_period: int = 9
81
+ ) -> dict:
82
+ """
83
+ MACD (Moving Average Convergence Divergence).
84
+
85
+ Args:
86
+ closes: Liste der Schlusskurse (neueste am Ende)
87
+ fast: Schnelle EMA-Periode (Standard: 12)
88
+ slow: Langsame EMA-Periode (Standard: 26)
89
+ signal_period: Signal EMA-Periode (Standard: 9)
90
+
91
+ Returns:
92
+ Dict mit "line", "signal", "histogram"
93
+ """
94
+ if len(closes) < slow + signal_period:
95
+ return {"line": 0.0, "signal": 0.0, "histogram": 0.0}
96
+
97
+ arr = np.array(closes, dtype=np.float64)
98
+
99
+ # MACD Line = EMA(fast) - EMA(slow)
100
+ macd_values = []
101
+ fast_ema = np.mean(closes[:fast])
102
+ slow_ema = np.mean(closes[:slow])
103
+ fast_mult = 2.0 / (fast + 1)
104
+ slow_mult = 2.0 / (slow + 1)
105
+
106
+ for i in range(slow, len(arr)):
107
+ fast_ema = (arr[i] - fast_ema) * fast_mult + fast_ema
108
+ slow_ema = (arr[i] - slow_ema) * slow_mult + slow_ema
109
+ macd_values.append(fast_ema - slow_ema)
110
+
111
+ if len(macd_values) < signal_period:
112
+ line = macd_values[-1] if macd_values else 0.0
113
+ return {"line": round(line, 8), "signal": 0.0, "histogram": 0.0}
114
+
115
+ # Signal Line = EMA der MACD Line
116
+ signal = np.mean(macd_values[:signal_period])
117
+ signal_mult = 2.0 / (signal_period + 1)
118
+
119
+ for val in macd_values[signal_period:]:
120
+ signal = (val - signal) * signal_mult + signal
121
+
122
+ line = macd_values[-1]
123
+ histogram = line - signal
124
+
125
+ return {
126
+ "line": round(float(line), 8),
127
+ "signal": round(float(signal), 8),
128
+ "histogram": round(float(histogram), 8)
129
+ }
130
+
131
+
132
+ def calculate_atr(
133
+ highs: list[float],
134
+ lows: list[float],
135
+ closes: list[float],
136
+ period: int = 14
137
+ ) -> float:
138
+ """
139
+ Average True Range (ATR).
140
+
141
+ Args:
142
+ highs: Liste der Hochs (neueste am Ende)
143
+ lows: Liste der Tiefs (neueste am Ende)
144
+ closes: Liste der Schlusskurse (neueste am Ende)
145
+ period: ATR-Periode (Standard: 14)
146
+
147
+ Returns:
148
+ Aktueller ATR Wert
149
+ """
150
+ if len(highs) < period + 1:
151
+ return 0.0
152
+
153
+ high = np.array(highs, dtype=np.float64)
154
+ low = np.array(lows, dtype=np.float64)
155
+ close = np.array(closes, dtype=np.float64)
156
+
157
+ # True Range = max(high-low, |high-prev_close|, |low-prev_close|)
158
+ tr_values = []
159
+ for i in range(1, len(high)):
160
+ tr = max(
161
+ high[i] - low[i],
162
+ abs(high[i] - close[i - 1]),
163
+ abs(low[i] - close[i - 1])
164
+ )
165
+ tr_values.append(tr)
166
+
167
+ if len(tr_values) < period:
168
+ return float(np.mean(tr_values)) if tr_values else 0.0
169
+
170
+ # Wilder's Smoothing (wie RSI)
171
+ atr = np.mean(tr_values[:period])
172
+ for i in range(period, len(tr_values)):
173
+ atr = (atr * (period - 1) + tr_values[i]) / period
174
+
175
+ return round(float(atr), 8)
176
+
177
+
178
+ def calculate_volume_ratio(volumes: list[float], period: int) -> float:
179
+ """
180
+ Volume Ratio: Aktuelles Volumen / Durchschnitt der letzten N Kerzen.
181
+
182
+ Args:
183
+ volumes: Liste der Volumina (neueste am Ende)
184
+ period: Lookback-Periode
185
+
186
+ Returns:
187
+ Volume Ratio (z.B. 1.5 = 50% über Durchschnitt)
188
+ """
189
+ if len(volumes) < period + 1:
190
+ return 1.0
191
+
192
+ current = volumes[-1]
193
+ avg = np.mean(volumes[-(period + 1):-1])
194
+
195
+ if avg == 0:
196
+ return 1.0
197
+
198
+ return round(current / avg, 2)
screener/market_gate.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ market_gate.py — Stage 0: BTC-Markt-Gate.
3
+
4
+ Läuft VOR jedem Zyklus. Wenn einer der Checks fehlschlägt → kompletter Abbruch.
5
+ Ergebnis wird geloggt.
6
+ """
7
+
8
+ import logging
9
+ from dataclasses import dataclass
10
+
11
+ import config
12
+ from data.binance import (
13
+ get_ticker_24hr,
14
+ get_btc_4h_klines,
15
+ BinanceAPIError
16
+ )
17
+ from screener.indicators import calculate_ema
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class MarketGateResult:
24
+ """Ergebnis des Market Gates."""
25
+ passed: bool
26
+ reason: str
27
+ btc_4h_change_pct: float
28
+ btc_above_ema50: bool
29
+
30
+
31
+ def _get_btc_4h_change() -> float:
32
+ """
33
+ Berechnet die BTC 4h-Änderung in Prozent.
34
+
35
+ Returns:
36
+ Prozentuale Änderung der letzten 4h (z.B. -2.5 für -2.5%)
37
+ """
38
+ klines = get_btc_4h_klines(limit=2)
39
+ if len(klines) < 2:
40
+ return 0.0
41
+
42
+ prev_close = float(klines[-2][4]) # Close der vorletzten 4h-Kerze
43
+ current_close = float(klines[-1][4])
44
+
45
+ if prev_close == 0:
46
+ return 0.0
47
+
48
+ change_pct = ((current_close - prev_close) / prev_close) * 100
49
+ return round(change_pct, 2)
50
+
51
+
52
+ def _is_btc_above_ema50_4h() -> bool:
53
+ """
54
+ Prüft ob BTC über seinem 4h EMA50 liegt.
55
+
56
+ Returns:
57
+ True wenn BTC über EMA50
58
+ """
59
+ klines = get_btc_4h_klines(limit=50)
60
+ if len(klines) < 50:
61
+ return True # Wenn nicht genug Daten, neutral
62
+
63
+ closes = [float(k[4]) for k in klines]
64
+ ema50 = calculate_ema(closes, period=50)
65
+ current_price = closes[-1]
66
+
67
+ return current_price > ema50
68
+
69
+
70
+ def market_gate_check() -> MarketGateResult:
71
+ """
72
+ Führt alle Market-Gate-Checks durch.
73
+
74
+ Returns:
75
+ MarketGateResult mit passed=True/False und Begründung
76
+ """
77
+ try:
78
+ btc_4h_change = _get_btc_4h_change()
79
+ btc_above_ema50 = _is_btc_above_ema50_4h()
80
+ except BinanceAPIError as e:
81
+ logger.error(f"Market Gate: Binance API Fehler: {e}")
82
+ return MarketGateResult(
83
+ passed=False,
84
+ reason=f"BINANCE_ERROR: {e}",
85
+ btc_4h_change_pct=0.0,
86
+ btc_above_ema50=False
87
+ )
88
+
89
+ # Check 1: BTC Drop
90
+ if btc_4h_change < config.BTC_FILTER_4H_DROP:
91
+ reason = f"BTC_4H_DROP: {btc_4h_change}%"
92
+ logger.warning(f"Market Gate BLOCKED: {reason}")
93
+ return MarketGateResult(
94
+ passed=False,
95
+ reason=reason,
96
+ btc_4h_change_pct=btc_4h_change,
97
+ btc_above_ema50=btc_above_ema50
98
+ )
99
+
100
+ # Check 2: BTC unter EMA50
101
+ if not btc_above_ema50:
102
+ reason = "BTC_BELOW_EMA50_4H"
103
+ logger.warning(f"Market Gate BLOCKED: {reason}")
104
+ return MarketGateResult(
105
+ passed=False,
106
+ reason=reason,
107
+ btc_4h_change_pct=btc_4h_change,
108
+ btc_above_ema50=False
109
+ )
110
+
111
+ # Check 3: BTC Pump (Rotation Risk)
112
+ if btc_4h_change > config.BTC_FILTER_4H_PUMP:
113
+ reason = f"BTC_4H_PUMP_ROTATION_RISK: {btc_4h_change}%"
114
+ logger.warning(f"Market Gate BLOCKED: {reason}")
115
+ return MarketGateResult(
116
+ passed=False,
117
+ reason=reason,
118
+ btc_4h_change_pct=btc_4h_change,
119
+ btc_above_ema50=btc_above_ema50
120
+ )
121
+
122
+ # Alles OK
123
+ logger.info(
124
+ f"Market Gate PASSED | BTC 4h: {btc_4h_change}% | "
125
+ f"EMA50: {'über' if btc_above_ema50 else 'unter'}"
126
+ )
127
+ return MarketGateResult(
128
+ passed=True,
129
+ reason="OK",
130
+ btc_4h_change_pct=btc_4h_change,
131
+ btc_above_ema50=btc_above_ema50
132
+ )
screener/screener.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ screener.py — Stage 1: Score-System über Top 100 Coins.
3
+
4
+ K.O.-Kriterien → sofortiger Ausschluss.
5
+ Score-System → Kandidaten mit Score >= SCREENER_PASS_SCORE.
6
+ FOMO-Schutz → Ausschluss bei +10% in 8 Kerzen.
7
+ Max MAX_CANDIDATES_PER_CYCLE Coins durchlassen.
8
+ """
9
+
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+
13
+ import config
14
+ from data.binance import get_klines, BinanceAPIError
15
+ from screener.indicators import (
16
+ calculate_rsi,
17
+ calculate_ema,
18
+ calculate_macd,
19
+ calculate_atr,
20
+ calculate_volume_ratio,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class Candidate:
28
+ """Ein Coin der den Screener bestanden hat."""
29
+ symbol: str
30
+ score: int
31
+ reasons: list[str] = field(default_factory=list)
32
+ price: float = 0.0
33
+ atr: float = 0.0
34
+ indicators: dict = field(default_factory=dict)
35
+
36
+
37
+ def _extract_ohlcv(klines: list) -> dict:
38
+ """Extrahiert OHLCV Arrays aus Binance Kline-Daten."""
39
+ return {
40
+ "opens": [float(k[1]) for k in klines],
41
+ "highs": [float(k[2]) for k in klines],
42
+ "lows": [float(k[3]) for k in klines],
43
+ "closes": [float(k[4]) for k in klines],
44
+ "volumes": [float(k[5]) for k in klines],
45
+ }
46
+
47
+
48
+ def _check_fomo(closes: list[float]) -> bool:
49
+ """
50
+ FOMO-Schutz: Preis +10% in letzten 8 Kerzen → Ausschluss.
51
+
52
+ Returns:
53
+ True wenn FOMO (Ausschluss)
54
+ """
55
+ if len(closes) < config.FOMO_LOOKBACK_CANDLES:
56
+ return False
57
+
58
+ price_8_candles_ago = closes[-(config.FOMO_LOOKBACK_CANDLES + 1)]
59
+ current_price = closes[-1]
60
+
61
+ if price_8_candles_ago == 0:
62
+ return False
63
+
64
+ change_pct = ((current_price - price_8_candles_ago) / price_8_candles_ago) * 100
65
+ return change_pct >= config.FOMO_PRICE_RISE_PCT
66
+
67
+
68
+ def calculate_score(symbol: str, klines: list) -> tuple[int, list[str]]:
69
+ """
70
+ Berechnet den Score für einen Coin.
71
+
72
+ Args:
73
+ symbol: Coin Symbol (z.B. "ETHUSDT")
74
+ klines: OHLCV-Kerzen von Binance
75
+
76
+ Returns:
77
+ Tuple von (score, reasons_liste)
78
+ """
79
+ score = 0
80
+ reasons = []
81
+
82
+ ohlcv = _extract_ohlcv(klines)
83
+ closes = ohlcv["closes"]
84
+ volumes = ohlcv["volumes"]
85
+ highs = ohlcv["highs"]
86
+ lows = ohlcv["lows"]
87
+
88
+ # K.O.: Zu wenig Daten
89
+ if len(closes) < 50:
90
+ return 0, ["TOO_LITTLE_DATA"]
91
+
92
+ # FOMO-Schutz
93
+ if _check_fomo(closes):
94
+ return 0, ["FOMO_PROTECTION"]
95
+
96
+ # ── Volume Spike (3 Punkte) ──
97
+ vol_ratio_20 = calculate_volume_ratio(volumes, 20)
98
+ vol_ratio_5 = calculate_volume_ratio(volumes, 5)
99
+
100
+ if vol_ratio_20 > config.VOLUME_SPIKE_THRESHOLD and vol_ratio_5 > config.VOLUME_SPIKE_THRESHOLD:
101
+ score += 3
102
+ reasons.append(f"Volume_Spike_{vol_ratio_20:.1f}x")
103
+
104
+ # ── MACD Crossover (2 Punkte) ──
105
+ macd = calculate_macd(closes)
106
+ # Bullisches Kreuz: MACD Line kreuzt über Signal in letzten 3 Kerzen
107
+ if len(closes) >= 3:
108
+ # Historie der MACD berechnen (letzte 4 Kerzen für Kreuz-Erkennung)
109
+ macd_history = []
110
+ for i in range(max(0, len(closes) - 30), len(closes)):
111
+ subset = closes[:i + 1]
112
+ m = calculate_macd(subset)
113
+ macd_history.append(m)
114
+
115
+ if len(macd_history) >= 2:
116
+ prev = macd_history[-2]
117
+ curr = macd_history[-1]
118
+ if prev["line"] <= prev["signal"] and curr["line"] > curr["signal"]:
119
+ score += 2
120
+ reasons.append("MACD_Cross")
121
+
122
+ # ── RSI Momentum (1 Punkte) ──
123
+ rsi = calculate_rsi(closes)
124
+ if 50 <= rsi <= 65:
125
+ score += 1
126
+ reasons.append(f"RSI_Momentum_{rsi:.0f}")
127
+
128
+ # ── RSI Reversal (1 Punkte) ──
129
+ # RSI war < 30, in letzten 2 Kerzen nach oben durchbrochen
130
+ if len(closes) >= 16:
131
+ rsi_values = []
132
+ for i in range(max(0, len(closes) - 10), len(closes)):
133
+ subset = closes[:i + 1]
134
+ rsi_values.append(calculate_rsi(subset))
135
+
136
+ if len(rsi_values) >= 3:
137
+ if rsi_values[-3] < 30 and rsi_values[-1] > 30:
138
+ score += 1
139
+ reasons.append(f"RSI_Reversal_{rsi:.0f}")
140
+
141
+ # ── EMA Alignment (1 Punkte) ──
142
+ ema9 = calculate_ema(closes, 9)
143
+ ema21 = calculate_ema(closes, 21)
144
+ ema50 = calculate_ema(closes, 50)
145
+
146
+ if ema9 > ema21 > ema50:
147
+ score += 1
148
+ reasons.append("EMA_Alignment")
149
+
150
+ # ── Momentum (1 Punkte) ──
151
+ if len(closes) >= config.MOMENTUM_LOOKBACK_CANDLES:
152
+ price_8_ago = closes[-(config.MOMENTUM_LOOKBACK_CANDLES + 1)]
153
+ current = closes[-1]
154
+ if price_8_ago > 0:
155
+ momentum_pct = ((current - price_8_ago) / price_8_ago) * 100
156
+ if config.MOMENTUM_MIN_PCT <= momentum_pct <= config.MOMENTUM_MAX_PCT:
157
+ score += 1
158
+ reasons.append(f"Momentum_{momentum_pct:.1f}%")
159
+
160
+ return score, reasons
161
+
162
+
163
+ def run_screener(
164
+ universe: list,
165
+ blacklist: set[str] | None = None
166
+ ) -> list[Candidate]:
167
+ """
168
+ Führt den Screener über das gesamte Universe aus.
169
+
170
+ Args:
171
+ universe: Liste von CoinInfo Objekten
172
+ blacklist: Set von Symbolen die ausgeschlossen sind
173
+
174
+ Returns:
175
+ Liste von Kandidaten mit Score >= SCREENER_PASS_SCORE
176
+ """
177
+ if blacklist is None:
178
+ blacklist = set()
179
+
180
+ candidates = []
181
+
182
+ for coin in universe:
183
+ symbol = coin.symbol
184
+
185
+ # K.O.: Blacklist
186
+ if symbol in blacklist:
187
+ continue
188
+
189
+ try:
190
+ klines = get_klines(symbol, interval="15m", limit=100)
191
+ except BinanceAPIError as e:
192
+ logger.debug(f"Screener: {symbol} übersprungen — {e}")
193
+ continue
194
+
195
+ score, reasons = calculate_score(symbol, klines)
196
+
197
+ if score >= config.SCREENER_PASS_SCORE:
198
+ atr = calculate_atr(
199
+ [float(k[2]) for k in klines],
200
+ [float(k[3]) for k in klines],
201
+ [float(k[4]) for k in klines]
202
+ )
203
+
204
+ candidate = Candidate(
205
+ symbol=symbol,
206
+ score=score,
207
+ reasons=reasons,
208
+ price=coin.price,
209
+ atr=atr,
210
+ indicators={
211
+ "rsi": calculate_rsi([float(k[4]) for k in klines]),
212
+ "macd": calculate_macd([float(k[4]) for k in klines]),
213
+ "ema9": calculate_ema([float(k[4]) for k in klines], 9),
214
+ "ema21": calculate_ema([float(k[4]) for k in klines], 21),
215
+ "ema50": calculate_ema([float(k[4]) for k in klines], 50),
216
+ "volume_ratio_20": calculate_volume_ratio(
217
+ [float(k[5]) for k in klines], 20
218
+ ),
219
+ "volume_ratio_5": calculate_volume_ratio(
220
+ [float(k[5]) for k in klines], 5
221
+ ),
222
+ }
223
+ )
224
+ candidates.append(candidate)
225
+ logger.info(f"Screener: {symbol} → Score {score} | {', '.join(reasons)}")
226
+
227
+ # Sortieren nach Score absteigend
228
+ candidates.sort(key=lambda c: c.score, reverse=True)
229
+
230
+ # Max Kandidaten begrenzen
231
+ result = candidates[:config.MAX_CANDIDATES_PER_CYCLE]
232
+
233
+ logger.info(
234
+ f"Screener abgeschlossen: {len(universe)} Coins geprüft, "
235
+ f"{len(candidates)} bestanden, {len(result)} ausgewählt"
236
+ )
237
+
238
+ return result