aalienau-cmyk commited on
Commit ·
16bbbb2
0
Parent(s):
Initial commit: Trading Bot v1.0 - Paper Trading mit KI
Browse files- .env.example +15 -0
- .gitignore +8 -0
- Procfile +1 -0
- README.md +107 -0
- ai/__init__.py +1 -0
- ai/entry_prompt.py +165 -0
- ai/exit_prompt.py +162 -0
- ai/provider.py +196 -0
- ai/validator.py +160 -0
- app.py +266 -0
- config.py +121 -0
- dashboard/__init__.py +1 -0
- dashboard/dashboard.py +52 -0
- data/__init__.py +1 -0
- data/binance.py +132 -0
- data/universe.py +103 -0
- db/__init__.py +1 -0
- db/supabase_client.py +375 -0
- positions/__init__.py +1 -0
- positions/exit_logic.py +392 -0
- positions/manager.py +216 -0
- requirements.txt +10 -0
- run_ai_test.py +235 -0
- run_db_test.py +158 -0
- run_exit_test.py +224 -0
- run_full_test.py +175 -0
- run_screener_test.py +102 -0
- schema.sql +74 -0
- screener/__init__.py +1 -0
- screener/indicators.py +198 -0
- screener/market_gate.py +132 -0
- screener/screener.py +238 -0
.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
|