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 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('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,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('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,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, entry_price, current_price, pnl_percent,
142
- duration_minutes, initial_stop, current_trailing_stop,
143
- tp1_hit, hold_count, ohlcv_data, indicators,
144
- sell_reason, btc_context
 
 
 
 
 
 
 
 
 
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
- if not gate_result.passed:
118
- logger.info(f"Market Gate BLOCKED: {gate_result.reason}")
119
- if db_configured():
120
- log_scan(result)
121
- return result
122
 
123
- # ── Stage 1: Universe + Screener ──
124
- try:
125
- universe = get_top100_universe()
126
- except Exception as e:
127
- result["errors"].append(f"Universe Fehler: {e}")
128
- if db_configured():
129
- log_scan(result)
130
- return result
 
 
 
 
 
 
 
 
 
131
 
132
- # Blacklist aus DB laden (Coins nach Stop-Loss-Cooldown ausschließen)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  blacklist = set()
134
- if db_configured():
135
- blacklist = {coin.symbol for coin in universe if is_blacklisted(coin.symbol)}
136
- if blacklist:
137
- logger.info(f"Blacklist aktiv: {blacklist}")
 
 
 
 
 
 
 
 
 
138
 
139
- candidates = run_screener(universe, blacklist=blacklist)
140
- result["candidates_found"] = len(candidates)
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=decision["stop_loss"],
197
- take_profit_1=decision["take_profit_1"],
198
- take_profit_2=decision["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 # 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,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 # 15min Change
86
- BTC_CRASH_CONFIRM_PCT = -2.5 # 1h Change
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", "") # Leer = Provider-Default
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
- df = df[
40
- [
 
 
41
  "symbol",
42
  "entry_price",
43
  "position_size_usd",
44
  "stop_loss",
45
  "take_profit_1",
46
  "opened_at",
47
- "learning_tags",
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
- """Prüft ob Symbol ein Leveraged Token ist."""
43
- return any(pattern in symbol.upper() for pattern in config.LEVERAGED_TOKEN_PATTERNS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(10)
290
  .execute()
291
  )
292
 
293
  if not result.data:
294
  return None
295
 
296
- # Nimm den ältesten Eintrag der letzten ~20 Minuten
297
- # (da wir nur alle 5 Min loggen, ist der 3. Eintrag ca. 15 Min alt)
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
- "learning_tags": learning_tags or [],
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
- # Bei Stop Loss → Blacklist
134
- if ("Stop" in exit_reason or "Stop_Loss" in exit_reason) and symbol:
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
- "pnl_pct": round(pnl_pct, 2),
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"(P&L: {pnl_pct:.2f}%) — Stop auf Break-Even"
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
- return score, reasons
 
 
 
 
 
 
 
 
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
- rsi_21 - 50.0
84
- ), f"Kürzere Periode sollte sensitiver sein: rsi_7={rsi_7}, rsi_21={rsi_21}"
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
- is_valid is False
46
- ), f"Entscheidung '{decision_type}' hätte abgelehnt werden sollen"
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
- decision["new_trailing_stop_usd"] is None
189
- ), "Trailing Stop über Preis hätte gelöscht werden müssen"
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
- decision["new_trailing_stop_usd"] is None
205
- ), "Schlechterer Trailing Stop hätte ignoriert werden müssen"
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
- decision["new_trailing_stop_usd"] == 1980.0
219
- ), "Gültiger Trailing Stop-Verbesserung hätte behalten werden sollen"
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
  # ──────────────────────────────────────────────