aalienau-cmyk commited on
Commit ·
ba0fd74
1
Parent(s): e898f62
Apply all bug fixes from BUGFIX_AUFTRAG.md, implement dashboard enhancements (trade history, equity curve, stats, events), fix screener double calculation, update AI provider to Ollama with Nemotron-3-Super, and include all changes from previous session
Browse files- ai/entry_prompt.py +14 -26
- ai/exit_prompt.py +25 -27
- ai/provider.py +1 -2
- ai/validator.py +9 -0
- app.py +97 -73
- config.py +5 -5
- dashboard/dashboard.py +7 -3
- data/universe.py +17 -2
- db/supabase_client.py +112 -3
- positions/manager.py +12 -7
- schema.sql +1 -0
- screener/screener.py +32 -24
- tests/test_indicators.py +3 -3
- tests/test_validator.py +12 -12
ai/entry_prompt.py
CHANGED
|
@@ -42,7 +42,7 @@ def build_entry_prompt(
|
|
| 42 |
ohlcv_data: list,
|
| 43 |
indicators: dict,
|
| 44 |
screener_reason: str,
|
| 45 |
-
btc_context: dict
|
| 46 |
) -> tuple[str, str]:
|
| 47 |
"""
|
| 48 |
Baut den Entry-Prompt.
|
|
@@ -82,19 +82,19 @@ TECHNISCHE DATEN (Letzte 10 Kerzen, OHLCV):
|
|
| 82 |
{ohlcv_table}
|
| 83 |
|
| 84 |
INDIKATOREN:
|
| 85 |
-
- RSI (14): {indicators.get(
|
| 86 |
-
- MACD: Line={indicators.get(
|
| 87 |
-
- EMA: 9={indicators.get(
|
| 88 |
-
- Volume Ratio (vs. 20-Candle-Avg): {indicators.get(
|
| 89 |
-
- Volume Ratio (vs. 5-Candle-Avg): {indicators.get(
|
| 90 |
-
- ATR (14, 15min): {indicators.get(
|
| 91 |
|
| 92 |
SCREENER-BEGRÜNDUNG:
|
| 93 |
{screener_reason}
|
| 94 |
|
| 95 |
MARKTKONTEXT:
|
| 96 |
-
- BTC 4h Change: {btc_context.get(
|
| 97 |
-
- BTC über EMA 50 (4h): {btc_context.get(
|
| 98 |
|
| 99 |
Antworte AUSSCHLIESSLICH in diesem JSON-Format, kein Text davor/danach:
|
| 100 |
{{
|
|
@@ -123,7 +123,7 @@ def get_entry_decision(
|
|
| 123 |
ohlcv_data: list,
|
| 124 |
indicators: dict,
|
| 125 |
screener_reason: str,
|
| 126 |
-
btc_context: dict
|
| 127 |
) -> dict:
|
| 128 |
"""
|
| 129 |
Ruft KI für Entry-Entscheidung auf.
|
|
@@ -142,8 +142,7 @@ def get_entry_decision(
|
|
| 142 |
Bei Fehler: {"decision": "SKIP", "reason": "..."}
|
| 143 |
"""
|
| 144 |
system_prompt, user_prompt = build_entry_prompt(
|
| 145 |
-
symbol, price, position_size_usd, ohlcv_data,
|
| 146 |
-
indicators, screener_reason, btc_context
|
| 147 |
)
|
| 148 |
|
| 149 |
try:
|
|
@@ -155,23 +154,12 @@ def get_entry_decision(
|
|
| 155 |
result["decision"] = result.get("decision", "SKIP").upper()
|
| 156 |
result["confidence"] = int(result.get("confidence", 0))
|
| 157 |
|
| 158 |
-
logger.info(
|
| 159 |
-
f"KI Entry {symbol}: {result['decision']} "
|
| 160 |
-
f"(Confidence: {result['confidence']})"
|
| 161 |
-
)
|
| 162 |
return result
|
| 163 |
|
| 164 |
except AIProviderError as e:
|
| 165 |
logger.error(f"KI Entry Fehler für {symbol}: {e}")
|
| 166 |
-
return {
|
| 167 |
-
"decision": "SKIP",
|
| 168 |
-
"confidence": 0,
|
| 169 |
-
"reason": f"KI_ERROR: {e}"
|
| 170 |
-
}
|
| 171 |
except ValueError as e:
|
| 172 |
logger.error(f"KI Entry JSON-Fehler für {symbol}: {e}")
|
| 173 |
-
return {
|
| 174 |
-
"decision": "SKIP",
|
| 175 |
-
"confidence": 0,
|
| 176 |
-
"reason": f"JSON_PARSE_ERROR: {e}"
|
| 177 |
-
}
|
|
|
|
| 42 |
ohlcv_data: list,
|
| 43 |
indicators: dict,
|
| 44 |
screener_reason: str,
|
| 45 |
+
btc_context: dict,
|
| 46 |
) -> tuple[str, str]:
|
| 47 |
"""
|
| 48 |
Baut den Entry-Prompt.
|
|
|
|
| 82 |
{ohlcv_table}
|
| 83 |
|
| 84 |
INDIKATOREN:
|
| 85 |
+
- RSI (14): {indicators.get("rsi", "N/A")}
|
| 86 |
+
- 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")}
|
| 87 |
+
- EMA: 9={indicators.get("ema9", "N/A")}, 21={indicators.get("ema21", "N/A")}, 50={indicators.get("ema50", "N/A")}
|
| 88 |
+
- Volume Ratio (vs. 20-Candle-Avg): {indicators.get("volume_ratio_20", "N/A")}x
|
| 89 |
+
- Volume Ratio (vs. 5-Candle-Avg): {indicators.get("volume_ratio_5", "N/A")}x
|
| 90 |
+
- ATR (14, 15min): {indicators.get("atr", "N/A")} USD
|
| 91 |
|
| 92 |
SCREENER-BEGRÜNDUNG:
|
| 93 |
{screener_reason}
|
| 94 |
|
| 95 |
MARKTKONTEXT:
|
| 96 |
+
- BTC 4h Change: {btc_context.get("btc_4h_pct", "N/A")}%
|
| 97 |
+
- BTC über EMA 50 (4h): {btc_context.get("btc_above_ema50", "N/A")}
|
| 98 |
|
| 99 |
Antworte AUSSCHLIESSLICH in diesem JSON-Format, kein Text davor/danach:
|
| 100 |
{{
|
|
|
|
| 123 |
ohlcv_data: list,
|
| 124 |
indicators: dict,
|
| 125 |
screener_reason: str,
|
| 126 |
+
btc_context: dict,
|
| 127 |
) -> dict:
|
| 128 |
"""
|
| 129 |
Ruft KI für Entry-Entscheidung auf.
|
|
|
|
| 142 |
Bei Fehler: {"decision": "SKIP", "reason": "..."}
|
| 143 |
"""
|
| 144 |
system_prompt, user_prompt = build_entry_prompt(
|
| 145 |
+
symbol, price, position_size_usd, ohlcv_data, indicators, screener_reason, btc_context
|
|
|
|
| 146 |
)
|
| 147 |
|
| 148 |
try:
|
|
|
|
| 154 |
result["decision"] = result.get("decision", "SKIP").upper()
|
| 155 |
result["confidence"] = int(result.get("confidence", 0))
|
| 156 |
|
| 157 |
+
logger.info(f"KI Entry {symbol}: {result['decision']} (Confidence: {result['confidence']})")
|
|
|
|
|
|
|
|
|
|
| 158 |
return result
|
| 159 |
|
| 160 |
except AIProviderError as e:
|
| 161 |
logger.error(f"KI Entry Fehler für {symbol}: {e}")
|
| 162 |
+
return {"decision": "SKIP", "confidence": 0, "reason": f"KI_ERROR: {e}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
except ValueError as e:
|
| 164 |
logger.error(f"KI Entry JSON-Fehler für {symbol}: {e}")
|
| 165 |
+
return {"decision": "SKIP", "confidence": 0, "reason": f"JSON_PARSE_ERROR: {e}"}
|
|
|
|
|
|
|
|
|
|
|
|
ai/exit_prompt.py
CHANGED
|
@@ -44,7 +44,7 @@ def build_exit_prompt(
|
|
| 44 |
ohlcv_data: list,
|
| 45 |
indicators: dict,
|
| 46 |
sell_reason: str,
|
| 47 |
-
btc_context: dict
|
| 48 |
) -> tuple[str, str]:
|
| 49 |
"""
|
| 50 |
Baut den Exit-Prompt.
|
|
@@ -85,18 +85,18 @@ TECHNISCHE DATEN (Letzte 10 Kerzen, 15min):
|
|
| 85 |
{ohlcv_table}
|
| 86 |
|
| 87 |
INDIKATOREN:
|
| 88 |
-
- RSI (14): {indicators.get(
|
| 89 |
-
- MACD: Line={indicators.get(
|
| 90 |
-
- EMA 9: {indicators.get(
|
| 91 |
-
- Volume Ratio (vs. 20-Candle-Avg): {indicators.get(
|
| 92 |
-
- ATR (14, 15min): {indicators.get(
|
| 93 |
|
| 94 |
SCREENER-WARNUNG:
|
| 95 |
{sell_reason}
|
| 96 |
|
| 97 |
MARKTKONTEXT:
|
| 98 |
-
- BTC 15min Change: {btc_context.get(
|
| 99 |
-
- BTC 1h Change: {btc_context.get(
|
| 100 |
|
| 101 |
Antworte AUSSCHLIESSLICH in diesem JSON-Format, kein Text davor/danach:
|
| 102 |
{{
|
|
@@ -128,7 +128,7 @@ def get_exit_decision(
|
|
| 128 |
ohlcv_data: list,
|
| 129 |
indicators: dict,
|
| 130 |
sell_reason: str,
|
| 131 |
-
btc_context: dict
|
| 132 |
) -> dict:
|
| 133 |
"""
|
| 134 |
Ruft KI für Exit-Entscheidung auf.
|
|
@@ -138,10 +138,19 @@ def get_exit_decision(
|
|
| 138 |
Bei Fehler: {"decision": "HOLD", "reason": "..."}
|
| 139 |
"""
|
| 140 |
system_prompt, user_prompt = build_exit_prompt(
|
| 141 |
-
symbol,
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
)
|
| 146 |
|
| 147 |
try:
|
|
@@ -153,23 +162,12 @@ def get_exit_decision(
|
|
| 153 |
result["decision"] = result.get("decision", "HOLD").upper()
|
| 154 |
result["confidence"] = int(result.get("confidence", 0))
|
| 155 |
|
| 156 |
-
logger.info(
|
| 157 |
-
f"KI Exit {symbol}: {result['decision']} "
|
| 158 |
-
f"(Confidence: {result['confidence']})"
|
| 159 |
-
)
|
| 160 |
return result
|
| 161 |
|
| 162 |
except AIProviderError as e:
|
| 163 |
logger.error(f"KI Exit Fehler für {symbol}: {e}")
|
| 164 |
-
return {
|
| 165 |
-
"decision": "HOLD",
|
| 166 |
-
"confidence": 0,
|
| 167 |
-
"reason": f"KI_ERROR: {e}"
|
| 168 |
-
}
|
| 169 |
except ValueError as e:
|
| 170 |
logger.error(f"KI Exit JSON-Fehler für {symbol}: {e}")
|
| 171 |
-
return {
|
| 172 |
-
"decision": "HOLD",
|
| 173 |
-
"confidence": 0,
|
| 174 |
-
"reason": f"JSON_PARSE_ERROR: {e}"
|
| 175 |
-
}
|
|
|
|
| 44 |
ohlcv_data: list,
|
| 45 |
indicators: dict,
|
| 46 |
sell_reason: str,
|
| 47 |
+
btc_context: dict,
|
| 48 |
) -> tuple[str, str]:
|
| 49 |
"""
|
| 50 |
Baut den Exit-Prompt.
|
|
|
|
| 85 |
{ohlcv_table}
|
| 86 |
|
| 87 |
INDIKATOREN:
|
| 88 |
+
- RSI (14): {indicators.get("rsi", "N/A")}
|
| 89 |
+
- 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")}
|
| 90 |
+
- EMA 9: {indicators.get("ema9", "N/A")} | Preis vs. EMA 9: {"über" if current_price > indicators.get("ema9", 0) else "unter"}
|
| 91 |
+
- Volume Ratio (vs. 20-Candle-Avg): {indicators.get("volume_ratio_20", "N/A")}x
|
| 92 |
+
- ATR (14, 15min): {indicators.get("atr", "N/A")} USD
|
| 93 |
|
| 94 |
SCREENER-WARNUNG:
|
| 95 |
{sell_reason}
|
| 96 |
|
| 97 |
MARKTKONTEXT:
|
| 98 |
+
- BTC 15min Change: {btc_context.get("btc_15min_pct", "N/A")}%
|
| 99 |
+
- BTC 1h Change: {btc_context.get("btc_1h_pct", "N/A")}%
|
| 100 |
|
| 101 |
Antworte AUSSCHLIESSLICH in diesem JSON-Format, kein Text davor/danach:
|
| 102 |
{{
|
|
|
|
| 128 |
ohlcv_data: list,
|
| 129 |
indicators: dict,
|
| 130 |
sell_reason: str,
|
| 131 |
+
btc_context: dict,
|
| 132 |
) -> dict:
|
| 133 |
"""
|
| 134 |
Ruft KI für Exit-Entscheidung auf.
|
|
|
|
| 138 |
Bei Fehler: {"decision": "HOLD", "reason": "..."}
|
| 139 |
"""
|
| 140 |
system_prompt, user_prompt = build_exit_prompt(
|
| 141 |
+
symbol,
|
| 142 |
+
entry_price,
|
| 143 |
+
current_price,
|
| 144 |
+
pnl_percent,
|
| 145 |
+
duration_minutes,
|
| 146 |
+
initial_stop,
|
| 147 |
+
current_trailing_stop,
|
| 148 |
+
tp1_hit,
|
| 149 |
+
hold_count,
|
| 150 |
+
ohlcv_data,
|
| 151 |
+
indicators,
|
| 152 |
+
sell_reason,
|
| 153 |
+
btc_context,
|
| 154 |
)
|
| 155 |
|
| 156 |
try:
|
|
|
|
| 162 |
result["decision"] = result.get("decision", "HOLD").upper()
|
| 163 |
result["confidence"] = int(result.get("confidence", 0))
|
| 164 |
|
| 165 |
+
logger.info(f"KI Exit {symbol}: {result['decision']} (Confidence: {result['confidence']})")
|
|
|
|
|
|
|
|
|
|
| 166 |
return result
|
| 167 |
|
| 168 |
except AIProviderError as e:
|
| 169 |
logger.error(f"KI Exit Fehler für {symbol}: {e}")
|
| 170 |
+
return {"decision": "HOLD", "confidence": 0, "reason": f"KI_ERROR: {e}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
except ValueError as e:
|
| 172 |
logger.error(f"KI Exit JSON-Fehler für {symbol}: {e}")
|
| 173 |
+
return {"decision": "HOLD", "confidence": 0, "reason": f"JSON_PARSE_ERROR: {e}"}
|
|
|
|
|
|
|
|
|
|
|
|
ai/provider.py
CHANGED
|
@@ -150,8 +150,7 @@ def get_provider() -> AIProvider:
|
|
| 150 |
return OllamaProvider()
|
| 151 |
else:
|
| 152 |
raise AIProviderError(
|
| 153 |
-
f"Unbekannter AI_PROVIDER: '{provider_type}'. "
|
| 154 |
-
f"Erlaubt: 'anthropic', 'openai', 'ollama'"
|
| 155 |
)
|
| 156 |
|
| 157 |
|
|
|
|
| 150 |
return OllamaProvider()
|
| 151 |
else:
|
| 152 |
raise AIProviderError(
|
| 153 |
+
f"Unbekannter AI_PROVIDER: '{provider_type}'. Erlaubt: 'anthropic', 'openai', 'ollama'"
|
|
|
|
| 154 |
)
|
| 155 |
|
| 156 |
|
ai/validator.py
CHANGED
|
@@ -45,7 +45,16 @@ def validate_entry_decision(decision: dict, current_price: float) -> tuple[bool,
|
|
| 45 |
# Risk-Reward Check
|
| 46 |
sl_distance = entry_price - stop_loss
|
| 47 |
tp1 = decision.get("take_profit_1", 0)
|
|
|
|
|
|
|
|
|
|
| 48 |
tp1_distance = tp1 - entry_price
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
if sl_distance <= 0:
|
| 51 |
return False, "INVALID_SL_DISTANCE"
|
|
|
|
| 45 |
# Risk-Reward Check
|
| 46 |
sl_distance = entry_price - stop_loss
|
| 47 |
tp1 = decision.get("take_profit_1", 0)
|
| 48 |
+
tp2 = decision.get("take_profit_2", 0)
|
| 49 |
+
|
| 50 |
+
# TP1 muss > entry sein
|
| 51 |
tp1_distance = tp1 - entry_price
|
| 52 |
+
if tp1 <= 0 or tp1_distance <= 0:
|
| 53 |
+
return False, "INVALID_TAKE_PROFIT_1"
|
| 54 |
+
|
| 55 |
+
# TP2 muss > TP1 sein (falls vorhanden)
|
| 56 |
+
if tp2 is not None and tp2 > 0 and tp2 <= tp1:
|
| 57 |
+
return False, f"TP2_MUST_BE_ABOVE_TP1: TP2={tp2} <= TP1={tp1}"
|
| 58 |
|
| 59 |
if sl_distance <= 0:
|
| 60 |
return False, "INVALID_SL_DISTANCE"
|
app.py
CHANGED
|
@@ -84,6 +84,18 @@ async def scan():
|
|
| 84 |
except Exception as e:
|
| 85 |
logger.error(f"CRITICAL: Scan cycle failed: {e}")
|
| 86 |
logger.error(traceback.format_exc())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
| 88 |
|
| 89 |
|
|
@@ -109,35 +121,80 @@ async def _run_scan_cycle() -> dict:
|
|
| 109 |
logger.warning(f"BTC-Preis konnte nicht geladen werden: {e}")
|
| 110 |
btc_price = 0
|
| 111 |
|
| 112 |
-
# ── Stage 0: Market Gate ──
|
| 113 |
gate_result = market_gate_check()
|
| 114 |
result["market_gate_passed"] = gate_result.passed
|
| 115 |
result["market_gate_reason"] = gate_result.reason
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
return result
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
blacklist = set()
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
|
| 142 |
# ── Stage 2+3: KI Entry + Validation ──
|
| 143 |
trades_opened = 0
|
|
@@ -168,6 +225,7 @@ async def _run_scan_cycle() -> dict:
|
|
| 168 |
)
|
| 169 |
except Exception as e:
|
| 170 |
logger.error(f"KI Entry Fehler für {candidate.symbol}: {e}")
|
|
|
|
| 171 |
continue
|
| 172 |
|
| 173 |
# Validation
|
|
@@ -189,13 +247,28 @@ async def _run_scan_cycle() -> dict:
|
|
| 189 |
continue
|
| 190 |
|
| 191 |
# Position öffnen
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
trade_id = open_position(
|
| 193 |
symbol=candidate.symbol,
|
| 194 |
entry_price=decision.get("entry_price", candidate.price),
|
| 195 |
position_size_usd=decision.get("position_size_usd", position_size),
|
| 196 |
-
stop_loss=
|
| 197 |
-
take_profit_1=
|
| 198 |
-
take_profit_2=
|
| 199 |
entry_atr=candidate.atr,
|
| 200 |
risk_reward_ratio=decision.get("risk_reward_ratio", 0),
|
| 201 |
learning_tags=decision.get("learning_tags", []),
|
|
@@ -206,55 +279,6 @@ async def _run_scan_cycle() -> dict:
|
|
| 206 |
|
| 207 |
result["trades_opened"] = trades_opened
|
| 208 |
|
| 209 |
-
# ── Exit-Check für offene Positionen ──
|
| 210 |
-
trades_closed = 0
|
| 211 |
-
open_positions = get_all_open_positions()
|
| 212 |
-
|
| 213 |
-
for position in open_positions:
|
| 214 |
-
try:
|
| 215 |
-
current_price = get_current_price(position["symbol"])
|
| 216 |
-
|
| 217 |
-
# BTC-Kontext für Exit-KI aus DB berechnen
|
| 218 |
-
btc_15min_price = get_btc_price_minutes_ago(15) if db_configured() else None
|
| 219 |
-
btc_1h_price = get_btc_price_minutes_ago(60) if db_configured() else None
|
| 220 |
-
btc_context = {
|
| 221 |
-
"btc_15min_pct": round(((btc_price - btc_15min_price) / btc_15min_price) * 100, 2)
|
| 222 |
-
if btc_15min_price and btc_price > 0
|
| 223 |
-
else 0,
|
| 224 |
-
"btc_1h_pct": round(((btc_price - btc_1h_price) / btc_1h_price) * 100, 2)
|
| 225 |
-
if btc_1h_price and btc_price > 0
|
| 226 |
-
else 0,
|
| 227 |
-
}
|
| 228 |
-
|
| 229 |
-
exit_decision = process_exit(position, current_price, btc_context)
|
| 230 |
-
|
| 231 |
-
if exit_decision == "SELL":
|
| 232 |
-
exit_reason = position.get("exit_reason", "AutoExit")
|
| 233 |
-
success = close_position(
|
| 234 |
-
trade_id=position["id"],
|
| 235 |
-
exit_price=current_price,
|
| 236 |
-
exit_reason=exit_reason,
|
| 237 |
-
symbol=position["symbol"],
|
| 238 |
-
)
|
| 239 |
-
if success:
|
| 240 |
-
trades_closed += 1
|
| 241 |
-
|
| 242 |
-
elif exit_decision == "PARTIAL_SELL":
|
| 243 |
-
exit_reason = position.get("exit_reason", "TP1_Partial_Exit")
|
| 244 |
-
success = close_partial_position(
|
| 245 |
-
trade_id=position["id"],
|
| 246 |
-
exit_price=current_price,
|
| 247 |
-
exit_reason=exit_reason,
|
| 248 |
-
symbol=position["symbol"],
|
| 249 |
-
)
|
| 250 |
-
if success:
|
| 251 |
-
trades_closed += 1
|
| 252 |
-
|
| 253 |
-
except Exception as e:
|
| 254 |
-
logger.error(f"Exit-Check Fehler für {position['symbol']}: {e}")
|
| 255 |
-
|
| 256 |
-
result["trades_closed"] = trades_closed
|
| 257 |
-
|
| 258 |
# ── Scan loggen ──
|
| 259 |
if db_configured():
|
| 260 |
log_scan(result)
|
|
|
|
| 84 |
except Exception as e:
|
| 85 |
logger.error(f"CRITICAL: Scan cycle failed: {e}")
|
| 86 |
logger.error(traceback.format_exc())
|
| 87 |
+
# Selbst im Fehlerfall scan_log schreiben, damit es im Dashboard sichtbar wird
|
| 88 |
+
if db_configured():
|
| 89 |
+
log_scan(
|
| 90 |
+
{
|
| 91 |
+
"market_gate_passed": False,
|
| 92 |
+
"market_gate_reason": "CRASH",
|
| 93 |
+
"errors": [str(e)],
|
| 94 |
+
"candidates_found": 0,
|
| 95 |
+
"trades_opened": 0,
|
| 96 |
+
"trades_closed": 0,
|
| 97 |
+
}
|
| 98 |
+
)
|
| 99 |
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
| 100 |
|
| 101 |
|
|
|
|
| 121 |
logger.warning(f"BTC-Preis konnte nicht geladen werden: {e}")
|
| 122 |
btc_price = 0
|
| 123 |
|
| 124 |
+
# ── Stage 0: Market Gate (nur für neue Entrys relevant) ──
|
| 125 |
gate_result = market_gate_check()
|
| 126 |
result["market_gate_passed"] = gate_result.passed
|
| 127 |
result["market_gate_reason"] = gate_result.reason
|
| 128 |
|
| 129 |
+
# ── Stage 4 (vorab): Exit-Check für OFFENE Positionen (unabhängig vom Gate!)
|
| 130 |
+
# ── WICHTIG: Wird VOR neuen Entries gemacht, falls Market Gate nicht passed
|
| 131 |
+
trades_closed = 0
|
| 132 |
+
open_positions = get_all_open_positions()
|
|
|
|
| 133 |
|
| 134 |
+
for position in open_positions:
|
| 135 |
+
try:
|
| 136 |
+
current_price = get_current_price(position["symbol"])
|
| 137 |
+
|
| 138 |
+
# BTC-Kontext für Exit-KI aus DB berechnen
|
| 139 |
+
btc_15min_price = get_btc_price_minutes_ago(15) if db_configured() else None
|
| 140 |
+
btc_1h_price = get_btc_price_minutes_ago(60) if db_configured() else None
|
| 141 |
+
btc_context = {
|
| 142 |
+
"btc_15min_pct": round(((btc_price - btc_15min_price) / btc_15min_price) * 100, 2)
|
| 143 |
+
if btc_15min_price and btc_price > 0
|
| 144 |
+
else 0,
|
| 145 |
+
"btc_1h_pct": round(((btc_price - btc_1h_price) / btc_1h_price) * 100, 2)
|
| 146 |
+
if btc_1h_price and btc_price > 0
|
| 147 |
+
else 0,
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
exit_decision = process_exit(position, current_price, btc_context)
|
| 151 |
|
| 152 |
+
if exit_decision == "SELL":
|
| 153 |
+
exit_reason = position.get("exit_reason", "AutoExit")
|
| 154 |
+
success = close_position(
|
| 155 |
+
trade_id=position["id"],
|
| 156 |
+
exit_price=current_price,
|
| 157 |
+
exit_reason=exit_reason,
|
| 158 |
+
symbol=position["symbol"],
|
| 159 |
+
)
|
| 160 |
+
if success:
|
| 161 |
+
trades_closed += 1
|
| 162 |
+
|
| 163 |
+
elif exit_decision == "PARTIAL_SELL":
|
| 164 |
+
exit_reason = position.get("exit_reason", "TP1_Partial_Exit")
|
| 165 |
+
success = close_partial_position(
|
| 166 |
+
trade_id=position["id"],
|
| 167 |
+
exit_price=current_price,
|
| 168 |
+
exit_reason=exit_reason,
|
| 169 |
+
symbol=position["symbol"],
|
| 170 |
+
)
|
| 171 |
+
if success:
|
| 172 |
+
trades_closed += 1
|
| 173 |
+
|
| 174 |
+
except Exception as e:
|
| 175 |
+
logger.error(f"Exit-Check Fehler für {position['symbol']}: {e}")
|
| 176 |
+
|
| 177 |
+
result["trades_closed"] = trades_closed
|
| 178 |
+
|
| 179 |
+
# ── Stage 1: Universe + Screener (nur wenn Gate passed) ──
|
| 180 |
+
universe = []
|
| 181 |
blacklist = set()
|
| 182 |
+
candidates = []
|
| 183 |
+
|
| 184 |
+
if gate_result.passed:
|
| 185 |
+
try:
|
| 186 |
+
universe = get_top100_universe()
|
| 187 |
+
except Exception as e:
|
| 188 |
+
result["errors"].append(f"Universe Fehler: {e}")
|
| 189 |
+
|
| 190 |
+
# Blacklist aus DB laden (Coins nach Stop-Loss-Cooldown ausschließen)
|
| 191 |
+
if db_configured():
|
| 192 |
+
blacklist = {coin.symbol for coin in universe if is_blacklisted(coin.symbol)}
|
| 193 |
+
if blacklist:
|
| 194 |
+
logger.info(f"Blacklist aktiv: {blacklist}")
|
| 195 |
|
| 196 |
+
candidates = run_screener(universe, blacklist=blacklist)
|
| 197 |
+
result["candidates_found"] = len(candidates)
|
| 198 |
|
| 199 |
# ── Stage 2+3: KI Entry + Validation ──
|
| 200 |
trades_opened = 0
|
|
|
|
| 225 |
)
|
| 226 |
except Exception as e:
|
| 227 |
logger.error(f"KI Entry Fehler für {candidate.symbol}: {e}")
|
| 228 |
+
result["errors"].append(f"KI Entry Fehler {candidate.symbol}: {e}")
|
| 229 |
continue
|
| 230 |
|
| 231 |
# Validation
|
|
|
|
| 247 |
continue
|
| 248 |
|
| 249 |
# Position öffnen
|
| 250 |
+
stop_loss = decision.get("stop_loss", 0)
|
| 251 |
+
take_profit_1 = decision.get("take_profit_1", 0)
|
| 252 |
+
take_profit_2 = decision.get("take_profit_2", 0)
|
| 253 |
+
|
| 254 |
+
# Validierung: Alle TP/SL Werte müssen vorhanden und > 0 sein
|
| 255 |
+
if stop_loss <= 0 or take_profit_1 <= 0:
|
| 256 |
+
logger.warning(
|
| 257 |
+
f"Entry übersprungen für {candidate.symbol}: "
|
| 258 |
+
f"stop_loss={stop_loss}, take_profit_1={take_profit_1}, take_profit_2={take_profit_2}"
|
| 259 |
+
)
|
| 260 |
+
result["errors"].append(
|
| 261 |
+
f"{candidate.symbol}: Ungültige TP/SL Werte (SL={stop_loss}, TP1={take_profit_1}, TP2={take_profit_2})"
|
| 262 |
+
)
|
| 263 |
+
continue
|
| 264 |
+
|
| 265 |
trade_id = open_position(
|
| 266 |
symbol=candidate.symbol,
|
| 267 |
entry_price=decision.get("entry_price", candidate.price),
|
| 268 |
position_size_usd=decision.get("position_size_usd", position_size),
|
| 269 |
+
stop_loss=stop_loss,
|
| 270 |
+
take_profit_1=take_profit_1,
|
| 271 |
+
take_profit_2=take_profit_2,
|
| 272 |
entry_atr=candidate.atr,
|
| 273 |
risk_reward_ratio=decision.get("risk_reward_ratio", 0),
|
| 274 |
learning_tags=decision.get("learning_tags", []),
|
|
|
|
| 279 |
|
| 280 |
result["trades_opened"] = trades_opened
|
| 281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
# ── Scan loggen ──
|
| 283 |
if db_configured():
|
| 284 |
log_scan(result)
|
config.py
CHANGED
|
@@ -57,8 +57,8 @@ COOLDOWN_AFTER_SL_HOURS = 4
|
|
| 57 |
# ATR-Multiplikatoren
|
| 58 |
# ──────────────────────────────────────────────
|
| 59 |
ATR_STOP_LOSS_MULT = 1.5
|
| 60 |
-
ATR_TP1_MULT = 3.0
|
| 61 |
-
ATR_TP2_MULT = 5.25
|
| 62 |
ATR_TRAILING_ACTIVATION_MULT = 1.5
|
| 63 |
ATR_TRAILING_DISTANCE_MULT = 1.5
|
| 64 |
|
|
@@ -82,8 +82,8 @@ MAX_HOLD_COUNT = 3
|
|
| 82 |
# ──────────────────────────────────────────────
|
| 83 |
BTC_FILTER_4H_DROP = -2.5
|
| 84 |
BTC_FILTER_4H_PUMP = 4.0
|
| 85 |
-
BTC_CRASH_FAST_PCT = -1.5
|
| 86 |
-
BTC_CRASH_CONFIRM_PCT = -2.5
|
| 87 |
|
| 88 |
|
| 89 |
# ──────────────────────────────────────────────
|
|
@@ -98,7 +98,7 @@ TIME_STOP_B_HOURS = 2
|
|
| 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", "")
|
| 102 |
|
| 103 |
|
| 104 |
# ──────────────────────────────────────────────
|
|
|
|
| 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 |
|
|
|
|
| 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 |
# ──────────────────────────────────────────────
|
|
|
|
| 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 |
# ──────────────────────────────────────────────
|
dashboard/dashboard.py
CHANGED
|
@@ -36,17 +36,21 @@ st.subheader("📈 Offene Positionen")
|
|
| 36 |
positions = get_open_positions()
|
| 37 |
if positions:
|
| 38 |
df = pd.DataFrame(positions)
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
| 41 |
"symbol",
|
| 42 |
"entry_price",
|
| 43 |
"position_size_usd",
|
| 44 |
"stop_loss",
|
| 45 |
"take_profit_1",
|
| 46 |
"opened_at",
|
| 47 |
-
"
|
| 48 |
]
|
|
|
|
| 49 |
]
|
|
|
|
| 50 |
st.dataframe(df, use_container_width=True)
|
| 51 |
else:
|
| 52 |
info = st.info("Keine offenen Positionen")
|
|
|
|
| 36 |
positions = get_open_positions()
|
| 37 |
if positions:
|
| 38 |
df = pd.DataFrame(positions)
|
| 39 |
+
# Fixed: DB column is "entry_learning_tags", not "learning_tags"
|
| 40 |
+
available_cols = [
|
| 41 |
+
c
|
| 42 |
+
for c in [
|
| 43 |
"symbol",
|
| 44 |
"entry_price",
|
| 45 |
"position_size_usd",
|
| 46 |
"stop_loss",
|
| 47 |
"take_profit_1",
|
| 48 |
"opened_at",
|
| 49 |
+
"entry_learning_tags",
|
| 50 |
]
|
| 51 |
+
if c in df.columns
|
| 52 |
]
|
| 53 |
+
df = df[available_cols]
|
| 54 |
st.dataframe(df, use_container_width=True)
|
| 55 |
else:
|
| 56 |
info = st.info("Keine offenen Positionen")
|
data/universe.py
CHANGED
|
@@ -39,8 +39,23 @@ def _is_stablecoin(symbol: str) -> bool:
|
|
| 39 |
|
| 40 |
|
| 41 |
def _is_leveraged_token(symbol: str) -> bool:
|
| 42 |
-
"""
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def _is_usdt_pair(symbol: str) -> bool:
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def _is_leveraged_token(symbol: str) -> bool:
|
| 42 |
+
"""
|
| 43 |
+
Prüft ob Symbol ein Leveraged Token ist.
|
| 44 |
+
|
| 45 |
+
Leveraged Tokens wie BTCUP, ETH3L, BTCBULL haben das Muster als Suffix
|
| 46 |
+
im BASE-Asset, nicht als Teilstring. JUPUSDT enthält "UP" als Teilstring
|
| 47 |
+
aber ist KEIN Leveraged Token.
|
| 48 |
+
"""
|
| 49 |
+
# Extrahiere Base-Asset (ohne Quote)
|
| 50 |
+
base = symbol
|
| 51 |
+
for quote in ("USDT", "USDC", "USD", "DAI", "TUSD", "BUSD", "GUSD"):
|
| 52 |
+
if symbol.endswith(quote):
|
| 53 |
+
base = symbol[: -len(quote)]
|
| 54 |
+
break
|
| 55 |
+
|
| 56 |
+
# Prüfe ob BASE-Asset mit einem Pattern endet (Suffix, nicht Teilstring)
|
| 57 |
+
base_upper = base.upper()
|
| 58 |
+
return any(base_upper.endswith(pattern) for pattern in config.LEVERAGED_TOKEN_PATTERNS)
|
| 59 |
|
| 60 |
|
| 61 |
def _is_usdt_pair(symbol: str) -> bool:
|
db/supabase_client.py
CHANGED
|
@@ -282,19 +282,25 @@ def get_btc_price_minutes_ago(minutes: int = 15) -> float | None:
|
|
| 282 |
|
| 283 |
try:
|
| 284 |
client = _get_client()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
result = (
|
| 286 |
client.table("btc_price_log")
|
| 287 |
.select("price")
|
| 288 |
.order("logged_at", desc=True)
|
| 289 |
-
.limit(
|
| 290 |
.execute()
|
| 291 |
)
|
| 292 |
|
| 293 |
if not result.data:
|
| 294 |
return None
|
| 295 |
|
| 296 |
-
# Nimm den
|
| 297 |
-
# (da wir nur alle 5 Min loggen, ist
|
| 298 |
index = min(minutes // 5, len(result.data) - 1)
|
| 299 |
return result.data[index]["price"]
|
| 300 |
|
|
@@ -416,3 +422,106 @@ def get_portfolio_value() -> float:
|
|
| 416 |
except Exception as e:
|
| 417 |
logger.error(f"Fehler bei Portfolio-Berechnung: {e}")
|
| 418 |
return config.PORTFOLIO_VALUE_USD
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
try:
|
| 284 |
client = _get_client()
|
| 285 |
+
# Fixed: Limit anpassen, um auch 60min vergangenheit abzudecken
|
| 286 |
+
# Wir brauchen mindestens minutes/5 + 2 Einträge (mit Puffer)
|
| 287 |
+
required_entries = minutes // 5 + 2
|
| 288 |
+
# Max 50 Einträge für größere Zeitspannen
|
| 289 |
+
limit = max(10, min(required_entries, 50))
|
| 290 |
+
|
| 291 |
result = (
|
| 292 |
client.table("btc_price_log")
|
| 293 |
.select("price")
|
| 294 |
.order("logged_at", desc=True)
|
| 295 |
+
.limit(limit)
|
| 296 |
.execute()
|
| 297 |
)
|
| 298 |
|
| 299 |
if not result.data:
|
| 300 |
return None
|
| 301 |
|
| 302 |
+
# Nimm den Eintrag für die angeforderte Zeitspanne
|
| 303 |
+
# (da wir nur alle 5 Min loggen, ist index = minutes//5 der richtige Eintrag)
|
| 304 |
index = min(minutes // 5, len(result.data) - 1)
|
| 305 |
return result.data[index]["price"]
|
| 306 |
|
|
|
|
| 422 |
except Exception as e:
|
| 423 |
logger.error(f"Fehler bei Portfolio-Berechnung: {e}")
|
| 424 |
return config.PORTFOLIO_VALUE_USD
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def get_all_trades(limit: int = 50) -> list[dict]:
|
| 428 |
+
"""
|
| 429 |
+
Gibt alle Trades zurück (offen + geschlossen).
|
| 430 |
+
|
| 431 |
+
Args:
|
| 432 |
+
limit: Anzahl der Einträge
|
| 433 |
+
|
| 434 |
+
Returns:
|
| 435 |
+
Liste von Trade-Dicts sortiert nach opened_at absteigend
|
| 436 |
+
"""
|
| 437 |
+
if not is_configured():
|
| 438 |
+
return []
|
| 439 |
+
|
| 440 |
+
try:
|
| 441 |
+
client = _get_client()
|
| 442 |
+
result = (
|
| 443 |
+
client.table("trades").select("*").order("opened_at", desc=True).limit(limit).execute()
|
| 444 |
+
)
|
| 445 |
+
return result.data
|
| 446 |
+
|
| 447 |
+
except Exception as e:
|
| 448 |
+
logger.error(f"Fehler beim Laden aller Trades: {e}")
|
| 449 |
+
return []
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def get_closed_trades(limit: int = 50) -> list[dict]:
|
| 453 |
+
"""
|
| 454 |
+
Gibt alle geschlossenen Trades zurück.
|
| 455 |
+
|
| 456 |
+
Args:
|
| 457 |
+
limit: Anzahl der Einträge
|
| 458 |
+
|
| 459 |
+
Returns:
|
| 460 |
+
Liste von Trade-Dicts sortiert nach closed_at aufsteigend
|
| 461 |
+
"""
|
| 462 |
+
if not is_configured():
|
| 463 |
+
return []
|
| 464 |
+
|
| 465 |
+
try:
|
| 466 |
+
client = _get_client()
|
| 467 |
+
result = (
|
| 468 |
+
client.table("trades")
|
| 469 |
+
.select("*")
|
| 470 |
+
.eq("status", "closed")
|
| 471 |
+
.order("closed_at", asc=True)
|
| 472 |
+
.limit(limit)
|
| 473 |
+
.execute()
|
| 474 |
+
)
|
| 475 |
+
return result.data
|
| 476 |
+
|
| 477 |
+
except Exception as e:
|
| 478 |
+
logger.error(f"Fehler beim Laden geschlossener Trades: {e}")
|
| 479 |
+
return []
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def get_trade_stats() -> dict:
|
| 483 |
+
"""
|
| 484 |
+
Berechnet Zusammenfassungskennzahlen.
|
| 485 |
+
|
| 486 |
+
Returns:
|
| 487 |
+
Dict mit open_count, closed_count, win_rate, total_pnl
|
| 488 |
+
"""
|
| 489 |
+
if not is_configured():
|
| 490 |
+
return {
|
| 491 |
+
"open_count": 0,
|
| 492 |
+
"closed_count": 0,
|
| 493 |
+
"win_rate": 0.0,
|
| 494 |
+
"total_pnl": 0.0,
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
try:
|
| 498 |
+
client = _get_client()
|
| 499 |
+
|
| 500 |
+
# Offene Positionen zählen
|
| 501 |
+
open_result = client.table("trades").select("id").eq("status", "open").execute()
|
| 502 |
+
open_count = len(open_result.data)
|
| 503 |
+
|
| 504 |
+
# Geschlossene Trades abrufen
|
| 505 |
+
closed_result = client.table("trades").select("pnl_usd").eq("status", "closed").execute()
|
| 506 |
+
closed_count = len(closed_result.data)
|
| 507 |
+
|
| 508 |
+
pnls = [p.get("pnl_usd", 0) or 0 for p in closed_result.data]
|
| 509 |
+
winning = sum(1 for pnl in pnls if pnl > 0)
|
| 510 |
+
win_rate = (winning / closed_count * 100) if closed_count > 0 else 0.0
|
| 511 |
+
total_pnl = sum(pnls)
|
| 512 |
+
|
| 513 |
+
return {
|
| 514 |
+
"open_count": open_count,
|
| 515 |
+
"closed_count": closed_count,
|
| 516 |
+
"win_rate": round(win_rate, 1),
|
| 517 |
+
"total_pnl": round(total_pnl, 2),
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
except Exception as e:
|
| 521 |
+
logger.error(f"Fehler bei Trade-Statistiken: {e}")
|
| 522 |
+
return {
|
| 523 |
+
"open_count": 0,
|
| 524 |
+
"closed_count": 0,
|
| 525 |
+
"win_rate": 0.0,
|
| 526 |
+
"total_pnl": 0.0,
|
| 527 |
+
}
|
positions/manager.py
CHANGED
|
@@ -65,7 +65,7 @@ def open_position(
|
|
| 65 |
"take_profit_2": take_profit_2,
|
| 66 |
"entry_atr": entry_atr,
|
| 67 |
"risk_reward_ratio": risk_reward_ratio,
|
| 68 |
-
"
|
| 69 |
}
|
| 70 |
|
| 71 |
trade_id = log_trade(position_data)
|
|
@@ -111,9 +111,12 @@ def close_position(
|
|
| 111 |
if position:
|
| 112 |
entry_price = position["entry_price"]
|
| 113 |
size = position["position_size_usd"]
|
|
|
|
|
|
|
| 114 |
if entry_price > 0:
|
|
|
|
| 115 |
pnl_pct = ((exit_price - entry_price) / entry_price) * 100
|
| 116 |
-
pnl_usd = size * (pnl_pct / 100)
|
| 117 |
|
| 118 |
success = close_trade(
|
| 119 |
trade_id=trade_id,
|
|
@@ -130,8 +133,8 @@ def close_position(
|
|
| 130 |
f"(P&L: {pnl_pct:.2f}%, ${pnl_usd:.2f}) — Grund: {exit_reason}"
|
| 131 |
)
|
| 132 |
|
| 133 |
-
#
|
| 134 |
-
if
|
| 135 |
add_to_blacklist(symbol, exit_reason)
|
| 136 |
|
| 137 |
return success
|
|
@@ -165,6 +168,9 @@ def close_partial_position(
|
|
| 165 |
size = position["position_size_usd"]
|
| 166 |
pnl_pct = ((exit_price - entry_price) / entry_price) * 100 if entry_price > 0 else 0
|
| 167 |
pnl_usd = size * (pnl_pct / 100)
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
# 50% verkaufen → position_size halbieren
|
| 170 |
new_size = size / 2
|
|
@@ -174,8 +180,7 @@ def close_partial_position(
|
|
| 174 |
"position_size_usd": new_size,
|
| 175 |
"stop_loss": entry_price * 1.001, # Break-Even + 0.1%
|
| 176 |
"tp1_hit": True,
|
| 177 |
-
"
|
| 178 |
-
"pnl_usd": round(pnl_usd / 2, 2),
|
| 179 |
}
|
| 180 |
|
| 181 |
success = update_position(trade_id, updates)
|
|
@@ -183,7 +188,7 @@ def close_partial_position(
|
|
| 183 |
if success:
|
| 184 |
logger.info(
|
| 185 |
f"Partial Exit (50%): {symbol} @ ${exit_price:.4f} "
|
| 186 |
-
f"(
|
| 187 |
)
|
| 188 |
|
| 189 |
return success
|
|
|
|
| 65 |
"take_profit_2": take_profit_2,
|
| 66 |
"entry_atr": entry_atr,
|
| 67 |
"risk_reward_ratio": risk_reward_ratio,
|
| 68 |
+
"entry_learning_tags": learning_tags or [], # Fixed: DB column name
|
| 69 |
}
|
| 70 |
|
| 71 |
trade_id = log_trade(position_data)
|
|
|
|
| 111 |
if position:
|
| 112 |
entry_price = position["entry_price"]
|
| 113 |
size = position["position_size_usd"]
|
| 114 |
+
# PnL aus TP1-Partial (wird in realized_pnl_usd gespeichert)
|
| 115 |
+
realized_pnl_usd = position.get("realized_pnl_usd", 0) or 0
|
| 116 |
if entry_price > 0:
|
| 117 |
+
# Gesamtes PnL = Realisiert (TP1) + nicht-realisiert (Rest)
|
| 118 |
pnl_pct = ((exit_price - entry_price) / entry_price) * 100
|
| 119 |
+
pnl_usd = size * (pnl_pct / 100) + realized_pnl_usd
|
| 120 |
|
| 121 |
success = close_trade(
|
| 122 |
trade_id=trade_id,
|
|
|
|
| 133 |
f"(P&L: {pnl_pct:.2f}%, ${pnl_usd:.2f}) — Grund: {exit_reason}"
|
| 134 |
)
|
| 135 |
|
| 136 |
+
# Nur bei Hard Stop Loss → Blacklist (nicht bei Trailing/Gewinn-Exits!)
|
| 137 |
+
if exit_reason == "Hit_Hard_Stop" and symbol:
|
| 138 |
add_to_blacklist(symbol, exit_reason)
|
| 139 |
|
| 140 |
return success
|
|
|
|
| 168 |
size = position["position_size_usd"]
|
| 169 |
pnl_pct = ((exit_price - entry_price) / entry_price) * 100 if entry_price > 0 else 0
|
| 170 |
pnl_usd = size * (pnl_pct / 100)
|
| 171 |
+
realized_pnl_usd = position.get("realized_pnl_usd", 0) or 0
|
| 172 |
+
# Addiere PnL der verkauften Hälfte zu realized_pnl_usd (nicht überschreiben!)
|
| 173 |
+
realized_pnl_usd += pnl_usd / 2
|
| 174 |
|
| 175 |
# 50% verkaufen → position_size halbieren
|
| 176 |
new_size = size / 2
|
|
|
|
| 180 |
"position_size_usd": new_size,
|
| 181 |
"stop_loss": entry_price * 1.001, # Break-Even + 0.1%
|
| 182 |
"tp1_hit": True,
|
| 183 |
+
"realized_pnl_usd": round(realized_pnl_usd, 2),
|
|
|
|
| 184 |
}
|
| 185 |
|
| 186 |
success = update_position(trade_id, updates)
|
|
|
|
| 188 |
if success:
|
| 189 |
logger.info(
|
| 190 |
f"Partial Exit (50%): {symbol} @ ${exit_price:.4f} "
|
| 191 |
+
f"(Realisiertes PnL: ${realized_pnl_usd:.2f}) — Stop auf Break-Even"
|
| 192 |
)
|
| 193 |
|
| 194 |
return success
|
schema.sql
CHANGED
|
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS trades (
|
|
| 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[],
|
|
|
|
| 16 |
entry_atr FLOAT NOT NULL DEFAULT 0,
|
| 17 |
pnl_pct FLOAT,
|
| 18 |
pnl_usd FLOAT,
|
| 19 |
+
realized_pnl_usd FLOAT DEFAULT 0,
|
| 20 |
risk_reward_ratio FLOAT,
|
| 21 |
entry_learning_tags TEXT[],
|
| 22 |
exit_learning_tags TEXT[],
|
screener/screener.py
CHANGED
|
@@ -15,7 +15,6 @@ from data.binance import get_klines, BinanceAPIError
|
|
| 15 |
from screener.indicators import (
|
| 16 |
calculate_rsi,
|
| 17 |
calculate_ema,
|
| 18 |
-
calculate_macd,
|
| 19 |
calculate_macd_series,
|
| 20 |
calculate_atr,
|
| 21 |
calculate_volume_ratio,
|
|
@@ -67,19 +66,20 @@ def _check_fomo(closes: list[float]) -> bool:
|
|
| 67 |
return change_pct >= config.FOMO_PRICE_RISE_PCT
|
| 68 |
|
| 69 |
|
| 70 |
-
def calculate_score(symbol: str, klines: list) -> tuple[int, list[str]]:
|
| 71 |
"""
|
| 72 |
-
Berechnet den Score für einen Coin.
|
| 73 |
|
| 74 |
Args:
|
| 75 |
symbol: Coin Symbol (z.B. "ETHUSDT")
|
| 76 |
klines: OHLCV-Kerzen von Binance
|
| 77 |
|
| 78 |
Returns:
|
| 79 |
-
Tuple von (score, reasons_liste)
|
| 80 |
"""
|
| 81 |
score = 0
|
| 82 |
reasons = []
|
|
|
|
| 83 |
|
| 84 |
ohlcv = _extract_ohlcv(klines)
|
| 85 |
closes = ohlcv["closes"]
|
|
@@ -87,11 +87,11 @@ def calculate_score(symbol: str, klines: list) -> tuple[int, list[str]]:
|
|
| 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)
|
|
@@ -100,6 +100,8 @@ def calculate_score(symbol: str, klines: list) -> tuple[int, list[str]]:
|
|
| 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_series = calculate_macd_series(closes)
|
|
@@ -109,12 +111,19 @@ def calculate_score(symbol: str, klines: list) -> tuple[int, list[str]]:
|
|
| 109 |
if prev["line"] <= prev["signal"] and curr["line"] > curr["signal"]:
|
| 110 |
score += 2
|
| 111 |
reasons.append("MACD_Cross")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
# ── RSI Momentum (1 Punkte) ──
|
| 114 |
rsi = calculate_rsi(closes)
|
| 115 |
if 50 <= rsi <= 65:
|
| 116 |
score += 1
|
| 117 |
reasons.append(f"RSI_Momentum_{rsi:.0f}")
|
|
|
|
| 118 |
|
| 119 |
# ── RSI Reversal (1 Punkte) ──
|
| 120 |
# RSI war < 30, in letzten 2 Kerzen nach oben durchbrochen
|
|
@@ -127,6 +136,7 @@ def calculate_score(symbol: str, klines: list) -> tuple[int, list[str]]:
|
|
| 127 |
if len(rsi_values) >= 3 and rsi_values[-3] < 30 and rsi_values[-1] > 30:
|
| 128 |
score += 1
|
| 129 |
reasons.append(f"RSI_Reversal_{rsi:.0f}")
|
|
|
|
| 130 |
|
| 131 |
# ── EMA Alignment (1 Punkte) ──
|
| 132 |
ema9 = calculate_ema(closes, 9)
|
|
@@ -136,6 +146,9 @@ def calculate_score(symbol: str, klines: list) -> tuple[int, list[str]]:
|
|
| 136 |
if ema9 > ema21 > ema50:
|
| 137 |
score += 1
|
| 138 |
reasons.append("EMA_Alignment")
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
# ── Momentum (1 Punkte) ──
|
| 141 |
if len(closes) >= config.MOMENTUM_LOOKBACK_CANDLES:
|
|
@@ -146,8 +159,17 @@ def calculate_score(symbol: str, klines: list) -> tuple[int, list[str]]:
|
|
| 146 |
if config.MOMENTUM_MIN_PCT <= momentum_pct <= config.MOMENTUM_MAX_PCT:
|
| 147 |
score += 1
|
| 148 |
reasons.append(f"Momentum_{momentum_pct:.1f}%")
|
|
|
|
| 149 |
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
|
| 153 |
def run_screener(universe: list, blacklist: set[str] | None = None) -> list[Candidate]:
|
|
@@ -179,30 +201,16 @@ def run_screener(universe: list, blacklist: set[str] | None = None) -> list[Cand
|
|
| 179 |
logger.debug(f"Screener: {symbol} übersprungen — {e}")
|
| 180 |
continue
|
| 181 |
|
| 182 |
-
score, reasons = calculate_score(symbol, klines)
|
| 183 |
|
| 184 |
if score >= config.SCREENER_PASS_SCORE:
|
| 185 |
-
atr = calculate_atr(
|
| 186 |
-
[float(k[2]) for k in klines],
|
| 187 |
-
[float(k[3]) for k in klines],
|
| 188 |
-
[float(k[4]) for k in klines],
|
| 189 |
-
)
|
| 190 |
-
|
| 191 |
candidate = Candidate(
|
| 192 |
symbol=symbol,
|
| 193 |
score=score,
|
| 194 |
reasons=reasons,
|
| 195 |
price=coin.price,
|
| 196 |
-
atr=atr,
|
| 197 |
-
indicators=
|
| 198 |
-
"rsi": calculate_rsi([float(k[4]) for k in klines]),
|
| 199 |
-
"macd": calculate_macd([float(k[4]) for k in klines]),
|
| 200 |
-
"ema9": calculate_ema([float(k[4]) for k in klines], 9),
|
| 201 |
-
"ema21": calculate_ema([float(k[4]) for k in klines], 21),
|
| 202 |
-
"ema50": calculate_ema([float(k[4]) for k in klines], 50),
|
| 203 |
-
"volume_ratio_20": calculate_volume_ratio([float(k[5]) for k in klines], 20),
|
| 204 |
-
"volume_ratio_5": calculate_volume_ratio([float(k[5]) for k in klines], 5),
|
| 205 |
-
},
|
| 206 |
)
|
| 207 |
candidates.append(candidate)
|
| 208 |
logger.info(f"Screener: {symbol} → Score {score} | {', '.join(reasons)}")
|
|
|
|
| 15 |
from screener.indicators import (
|
| 16 |
calculate_rsi,
|
| 17 |
calculate_ema,
|
|
|
|
| 18 |
calculate_macd_series,
|
| 19 |
calculate_atr,
|
| 20 |
calculate_volume_ratio,
|
|
|
|
| 66 |
return change_pct >= config.FOMO_PRICE_RISE_PCT
|
| 67 |
|
| 68 |
|
| 69 |
+
def calculate_score(symbol: str, klines: list) -> tuple[int, list[str], dict]:
|
| 70 |
"""
|
| 71 |
+
Berechnet den Score für einen Coin und liefert auch die verwendeten Indikatoren.
|
| 72 |
|
| 73 |
Args:
|
| 74 |
symbol: Coin Symbol (z.B. "ETHUSDT")
|
| 75 |
klines: OHLCV-Kerzen von Binance
|
| 76 |
|
| 77 |
Returns:
|
| 78 |
+
Tuple von (score, reasons_liste, indicators_dict)
|
| 79 |
"""
|
| 80 |
score = 0
|
| 81 |
reasons = []
|
| 82 |
+
indicators = {}
|
| 83 |
|
| 84 |
ohlcv = _extract_ohlcv(klines)
|
| 85 |
closes = ohlcv["closes"]
|
|
|
|
| 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)
|
|
|
|
| 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 |
+
indicators["volume_ratio_20"] = vol_ratio_20
|
| 104 |
+
indicators["volume_ratio_5"] = vol_ratio_5
|
| 105 |
|
| 106 |
# ── MACD Crossover (2 Punkte) ──
|
| 107 |
macd_series = calculate_macd_series(closes)
|
|
|
|
| 111 |
if prev["line"] <= prev["signal"] and curr["line"] > curr["signal"]:
|
| 112 |
score += 2
|
| 113 |
reasons.append("MACD_Cross")
|
| 114 |
+
indicators["macd"] = {
|
| 115 |
+
"line": curr["line"],
|
| 116 |
+
"signal": curr["signal"],
|
| 117 |
+
"histogram": curr["histogram"],
|
| 118 |
+
}
|
| 119 |
+
indicators["macd_series"] = macd_series[-2:] # keep last two for reference
|
| 120 |
|
| 121 |
# ── RSI Momentum (1 Punkte) ──
|
| 122 |
rsi = calculate_rsi(closes)
|
| 123 |
if 50 <= rsi <= 65:
|
| 124 |
score += 1
|
| 125 |
reasons.append(f"RSI_Momentum_{rsi:.0f}")
|
| 126 |
+
indicators["rsi"] = rsi
|
| 127 |
|
| 128 |
# ── RSI Reversal (1 Punkte) ──
|
| 129 |
# RSI war < 30, in letzten 2 Kerzen nach oben durchbrochen
|
|
|
|
| 136 |
if len(rsi_values) >= 3 and rsi_values[-3] < 30 and rsi_values[-1] > 30:
|
| 137 |
score += 1
|
| 138 |
reasons.append(f"RSI_Reversal_{rsi:.0f}")
|
| 139 |
+
indicators["rsi_reversal"] = True
|
| 140 |
|
| 141 |
# ── EMA Alignment (1 Punkte) ──
|
| 142 |
ema9 = calculate_ema(closes, 9)
|
|
|
|
| 146 |
if ema9 > ema21 > ema50:
|
| 147 |
score += 1
|
| 148 |
reasons.append("EMA_Alignment")
|
| 149 |
+
indicators["ema9"] = ema9
|
| 150 |
+
indicators["ema21"] = ema21
|
| 151 |
+
indicators["ema50"] = ema50
|
| 152 |
|
| 153 |
# ── Momentum (1 Punkte) ──
|
| 154 |
if len(closes) >= config.MOMENTUM_LOOKBACK_CANDLES:
|
|
|
|
| 159 |
if config.MOMENTUM_MIN_PCT <= momentum_pct <= config.MOMENTUM_MAX_PCT:
|
| 160 |
score += 1
|
| 161 |
reasons.append(f"Momentum_{momentum_pct:.1f}%")
|
| 162 |
+
indicators["momentum_pct"] = momentum_pct
|
| 163 |
|
| 164 |
+
# ATR (always needed for position sizing)
|
| 165 |
+
atr = calculate_atr(
|
| 166 |
+
[float(k[2]) for k in klines],
|
| 167 |
+
[float(k[3]) for k in klines],
|
| 168 |
+
[float(k[4]) for k in klines],
|
| 169 |
+
)
|
| 170 |
+
indicators["atr"] = atr
|
| 171 |
+
|
| 172 |
+
return score, reasons, indicators
|
| 173 |
|
| 174 |
|
| 175 |
def run_screener(universe: list, blacklist: set[str] | None = None) -> list[Candidate]:
|
|
|
|
| 201 |
logger.debug(f"Screener: {symbol} übersprungen — {e}")
|
| 202 |
continue
|
| 203 |
|
| 204 |
+
score, reasons, indicators = calculate_score(symbol, klines)
|
| 205 |
|
| 206 |
if score >= config.SCREENER_PASS_SCORE:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
candidate = Candidate(
|
| 208 |
symbol=symbol,
|
| 209 |
score=score,
|
| 210 |
reasons=reasons,
|
| 211 |
price=coin.price,
|
| 212 |
+
atr=indicators.get("atr", 0.0),
|
| 213 |
+
indicators=indicators,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
)
|
| 215 |
candidates.append(candidate)
|
| 216 |
logger.info(f"Screener: {symbol} → Score {score} | {', '.join(reasons)}")
|
tests/test_indicators.py
CHANGED
|
@@ -79,9 +79,9 @@ class TestRSI:
|
|
| 79 |
rsi_7 = calculate_rsi(closes, period=7)
|
| 80 |
rsi_21 = calculate_rsi(closes, period=21)
|
| 81 |
# Kürzere Periode reagiert stärker → weiter vom Neutralpunkt weg
|
| 82 |
-
assert abs(rsi_7 - 50.0) >= abs(
|
| 83 |
-
|
| 84 |
-
)
|
| 85 |
|
| 86 |
|
| 87 |
# ──────────────────────────────────────────────
|
|
|
|
| 79 |
rsi_7 = calculate_rsi(closes, period=7)
|
| 80 |
rsi_21 = calculate_rsi(closes, period=21)
|
| 81 |
# Kürzere Periode reagiert stärker → weiter vom Neutralpunkt weg
|
| 82 |
+
assert abs(rsi_7 - 50.0) >= abs(rsi_21 - 50.0), (
|
| 83 |
+
f"Kürzere Periode sollte sensitiver sein: rsi_7={rsi_7}, rsi_21={rsi_21}"
|
| 84 |
+
)
|
| 85 |
|
| 86 |
|
| 87 |
# ──────────────────────────────────────────────
|
tests/test_validator.py
CHANGED
|
@@ -41,9 +41,9 @@ class TestEntryValidation:
|
|
| 41 |
"position_size_usd": 50.0,
|
| 42 |
}
|
| 43 |
is_valid, reason = validate_entry_decision(decision, CURRENT_PRICE)
|
| 44 |
-
assert (
|
| 45 |
-
|
| 46 |
-
)
|
| 47 |
assert "NOT_BUY" in reason
|
| 48 |
|
| 49 |
def test_low_confidence_rejected(self, valid_entry_decision):
|
|
@@ -184,9 +184,9 @@ class TestExitValidation:
|
|
| 184 |
decision=decision, current_price=current_price, current_trailing_stop=None, hold_count=0
|
| 185 |
)
|
| 186 |
# Nach Validation muss der ungültige Stop gelöscht sein
|
| 187 |
-
assert (
|
| 188 |
-
|
| 189 |
-
)
|
| 190 |
|
| 191 |
def test_trailing_stop_lower_than_current_ignored(self):
|
| 192 |
"""Neuer Trailing Stop darf nicht schlechter als aktueller sein."""
|
|
@@ -200,9 +200,9 @@ class TestExitValidation:
|
|
| 200 |
current_trailing_stop=current_trailing,
|
| 201 |
hold_count=0,
|
| 202 |
)
|
| 203 |
-
assert (
|
| 204 |
-
|
| 205 |
-
)
|
| 206 |
|
| 207 |
def test_valid_improved_trailing_stop_kept(self):
|
| 208 |
"""Verbesserter Trailing Stop (höher, unter Preis) soll behalten werden."""
|
|
@@ -214,9 +214,9 @@ class TestExitValidation:
|
|
| 214 |
validate_exit_decision(
|
| 215 |
decision=decision, current_price=2000.0, current_trailing_stop=1950.0, hold_count=0
|
| 216 |
)
|
| 217 |
-
assert (
|
| 218 |
-
|
| 219 |
-
)
|
| 220 |
|
| 221 |
|
| 222 |
# ──────────────────────────────────────────────
|
|
|
|
| 41 |
"position_size_usd": 50.0,
|
| 42 |
}
|
| 43 |
is_valid, reason = validate_entry_decision(decision, CURRENT_PRICE)
|
| 44 |
+
assert is_valid is False, (
|
| 45 |
+
f"Entscheidung '{decision_type}' hätte abgelehnt werden sollen"
|
| 46 |
+
)
|
| 47 |
assert "NOT_BUY" in reason
|
| 48 |
|
| 49 |
def test_low_confidence_rejected(self, valid_entry_decision):
|
|
|
|
| 184 |
decision=decision, current_price=current_price, current_trailing_stop=None, hold_count=0
|
| 185 |
)
|
| 186 |
# Nach Validation muss der ungültige Stop gelöscht sein
|
| 187 |
+
assert decision["new_trailing_stop_usd"] is None, (
|
| 188 |
+
"Trailing Stop über Preis hätte gelöscht werden müssen"
|
| 189 |
+
)
|
| 190 |
|
| 191 |
def test_trailing_stop_lower_than_current_ignored(self):
|
| 192 |
"""Neuer Trailing Stop darf nicht schlechter als aktueller sein."""
|
|
|
|
| 200 |
current_trailing_stop=current_trailing,
|
| 201 |
hold_count=0,
|
| 202 |
)
|
| 203 |
+
assert decision["new_trailing_stop_usd"] is None, (
|
| 204 |
+
"Schlechterer Trailing Stop hätte ignoriert werden müssen"
|
| 205 |
+
)
|
| 206 |
|
| 207 |
def test_valid_improved_trailing_stop_kept(self):
|
| 208 |
"""Verbesserter Trailing Stop (höher, unter Preis) soll behalten werden."""
|
|
|
|
| 214 |
validate_exit_decision(
|
| 215 |
decision=decision, current_price=2000.0, current_trailing_stop=1950.0, hold_count=0
|
| 216 |
)
|
| 217 |
+
assert decision["new_trailing_stop_usd"] == 1980.0, (
|
| 218 |
+
"Gültiger Trailing Stop-Verbesserung hätte behalten werden sollen"
|
| 219 |
+
)
|
| 220 |
|
| 221 |
|
| 222 |
# ──────────────────────────────────────────────
|