""" test_validator.py — Tests für den Backend-Validator. KRITISCH: Der Validator ist der letzte Schutz bevor Geld bewegt wird. Jede Regel hier schützt vor echten finanziellen Fehlern. """ import sys import os sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) import config from ai.validator import validate_entry_decision, validate_exit_decision, calculate_position_size CURRENT_PRICE = 100.0 # ────────────────────────────────────────────── # Entry Validation Tests # ────────────────────────────────────────────── class TestEntryValidation: def test_valid_entry_passes(self, valid_entry_decision): """Eine korrekte Entry-Entscheidung soll durchgehen.""" is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE) assert is_valid is True, f"Valide Entry wurde abgelehnt: {reason}" assert reason == "OK" def test_not_buy_decision_rejected(self): """Alles außer BUY soll abgelehnt werden.""" for decision_type in ["SELL", "HOLD", "SKIP", "", None]: decision = { "decision": decision_type, "confidence": 9, "entry_price": 100.0, "stop_loss": 90.0, "take_profit_1": 120.0, "position_size_usd": 50.0, } is_valid, reason = validate_entry_decision(decision, CURRENT_PRICE) assert ( is_valid is False ), f"Entscheidung '{decision_type}' hätte abgelehnt werden sollen" assert "NOT_BUY" in reason def test_low_confidence_rejected(self, valid_entry_decision): """Confidence unter Minimum soll abgelehnt werden.""" valid_entry_decision["confidence"] = config.ENTRY_MIN_CONFIDENCE - 1 is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE) assert is_valid is False assert "CONFIDENCE" in reason def test_minimum_confidence_passes(self, valid_entry_decision): """Genau das Minimum soll noch durchgehen.""" valid_entry_decision["confidence"] = config.ENTRY_MIN_CONFIDENCE is_valid, _ = validate_entry_decision(valid_entry_decision, CURRENT_PRICE) assert is_valid is True def test_stop_loss_above_entry_rejected(self, valid_entry_decision): """Stop Loss über Entry → sofort ablehnen (würde sofort getriggert).""" valid_entry_decision["stop_loss"] = valid_entry_decision["entry_price"] + 5.0 is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE) assert is_valid is False assert "STOP_ABOVE_ENTRY" in reason def test_stop_loss_equal_to_entry_rejected(self, valid_entry_decision): """Stop Loss = Entry → auch ungültig.""" valid_entry_decision["stop_loss"] = valid_entry_decision["entry_price"] is_valid, _ = validate_entry_decision(valid_entry_decision, CURRENT_PRICE) assert is_valid is False def test_zero_stop_loss_rejected(self, valid_entry_decision): """Stop Loss = 0 → abgelehnt.""" valid_entry_decision["stop_loss"] = 0 is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE) assert is_valid is False assert "INVALID_STOP_LOSS" in reason def test_rr_too_low_rejected(self, valid_entry_decision): """R:R unter Minimum → abgelehnt (schlechtes Chance-Risiko-Verhältnis).""" # Entry: 100, SL: 95 (dist: 5), TP1 muss > 110 für R:R >= 2.0 # Setze TP1 auf 102 → R:R = (102-100)/(100-95) = 0.4 → abgelehnt valid_entry_decision["entry_price"] = 100.0 valid_entry_decision["stop_loss"] = 95.0 valid_entry_decision["take_profit_1"] = 102.0 # R:R = 0.4 is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE) assert is_valid is False assert "RR_TOO_LOW" in reason def test_exact_minimum_rr_passes(self, valid_entry_decision): """Genau das Minimum R:R soll noch durchgehen.""" entry = 100.0 sl = 95.0 # Abstand: 5 # Minimum R:R = 2.0 → TP1 mindestens bei 100 + 5*2 = 110 valid_entry_decision["entry_price"] = entry valid_entry_decision["stop_loss"] = sl valid_entry_decision["take_profit_1"] = 110.0 is_valid, _ = validate_entry_decision(valid_entry_decision, CURRENT_PRICE) assert is_valid is True def test_position_too_large_rejected(self, valid_entry_decision): """Position über 10% des Portfolios → abgelehnt.""" max_allowed = config.PORTFOLIO_VALUE_USD * 0.10 valid_entry_decision["position_size_usd"] = max_allowed + 1.0 is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE) assert is_valid is False assert "POSITION_TOO_LARGE" in reason def test_zero_position_size_rejected(self, valid_entry_decision): """Positionsgröße 0 → abgelehnt.""" valid_entry_decision["position_size_usd"] = 0 is_valid, reason = validate_entry_decision(valid_entry_decision, CURRENT_PRICE) assert is_valid is False assert "INVALID_POSITION_SIZE" in reason def test_negative_position_size_rejected(self, valid_entry_decision): """Negative Positionsgröße → abgelehnt.""" valid_entry_decision["position_size_usd"] = -10.0 is_valid, _ = validate_entry_decision(valid_entry_decision, CURRENT_PRICE) assert is_valid is False # ────────────────────────────────────────────── # Exit Validation Tests # ────────────────────────────────────────────── class TestExitValidation: def test_hold_count_exceeded_forces_sell(self): """Nach MAX_HOLD_COUNT HOLDs → Zwangsverkauf.""" decision = {"decision": "HOLD", "confidence": 9, "new_trailing_stop_usd": None} result = validate_exit_decision( decision=decision, current_price=2100.0, current_trailing_stop=None, hold_count=config.MAX_HOLD_COUNT, # genau am Limit ) assert result == "SELL", "Zwangsverkauf bei MAX_HOLD_COUNT nicht ausgelöst" def test_exactly_max_hold_count_forces_sell(self): """Exakt MAX_HOLD_COUNT soll auch Zwangsverkauf auslösen.""" decision = {"decision": "HOLD", "confidence": 8, "new_trailing_stop_usd": None} result = validate_exit_decision( decision=decision, current_price=2100.0, current_trailing_stop=None, hold_count=config.MAX_HOLD_COUNT, ) assert result == "SELL" def test_below_max_hold_count_respects_ai(self): """Unter MAX_HOLD_COUNT soll KI-Entscheidung respektiert werden.""" decision = {"decision": "HOLD", "confidence": 8, "new_trailing_stop_usd": None} result = validate_exit_decision( decision=decision, current_price=2100.0, current_trailing_stop=None, hold_count=config.MAX_HOLD_COUNT - 1, ) assert result == "HOLD" def test_low_exit_confidence_forces_sell(self): """Niedrige Confidence beim Exit → SELL (lieber zu früh raus).""" decision = { "decision": "HOLD", "confidence": config.EXIT_MIN_CONFIDENCE - 1, # zu niedrig "new_trailing_stop_usd": None, } result = validate_exit_decision( decision=decision, current_price=2100.0, current_trailing_stop=None, hold_count=0 ) assert result == "SELL" def test_trailing_stop_above_price_ignored(self): """Neuer Trailing Stop über aktuellem Preis → darf nicht gesetzt werden.""" current_price = 2000.0 invalid_stop = 2100.0 # über aktuellem Preis! decision = {"decision": "HOLD", "confidence": 9, "new_trailing_stop_usd": invalid_stop} validate_exit_decision( decision=decision, current_price=current_price, current_trailing_stop=None, hold_count=0 ) # Nach Validation muss der ungültige Stop gelöscht sein assert ( decision["new_trailing_stop_usd"] is None ), "Trailing Stop über Preis hätte gelöscht werden müssen" def test_trailing_stop_lower_than_current_ignored(self): """Neuer Trailing Stop darf nicht schlechter als aktueller sein.""" current_trailing = 1950.0 worse_stop = 1900.0 # schlechter (tiefer) als aktuell decision = {"decision": "HOLD", "confidence": 9, "new_trailing_stop_usd": worse_stop} validate_exit_decision( decision=decision, current_price=2000.0, current_trailing_stop=current_trailing, hold_count=0, ) assert ( decision["new_trailing_stop_usd"] is None ), "Schlechterer Trailing Stop hätte ignoriert werden müssen" def test_valid_improved_trailing_stop_kept(self): """Verbesserter Trailing Stop (höher, unter Preis) soll behalten werden.""" decision = { "decision": "HOLD", "confidence": 9, "new_trailing_stop_usd": 1980.0, # höher als aktuell (1950), unter Preis (2000) ✓ } validate_exit_decision( decision=decision, current_price=2000.0, current_trailing_stop=1950.0, hold_count=0 ) assert ( decision["new_trailing_stop_usd"] == 1980.0 ), "Gültiger Trailing Stop-Verbesserung hätte behalten werden sollen" # ────────────────────────────────────────────── # Position Size Calculation Tests # ────────────────────────────────────────────── class TestPositionSizeCalculation: def test_zero_entry_returns_zero(self): """Entry Price = 0 → keine Position.""" assert calculate_position_size(0.0, 90.0) == 0.0 def test_zero_stop_loss_returns_zero(self): """Stop Loss = 0 → keine Position.""" assert calculate_position_size(100.0, 0.0) == 0.0 def test_stop_above_entry_returns_zero(self): """Stop über Entry → kein sinnvoller Trade.""" assert calculate_position_size(100.0, 110.0) == 0.0 def test_stop_equal_to_entry_returns_zero(self): """Stop = Entry → Division durch 0 vermeiden.""" assert calculate_position_size(100.0, 100.0) == 0.0 def test_position_capped_at_10_percent(self): """Position darf nie mehr als 10% des Portfolios sein.""" max_allowed = config.PORTFOLIO_VALUE_USD * 0.10 # Sehr enges SL → würde riesige Position berechnen size = calculate_position_size(10000.0, 9999.99) assert size <= max_allowed, f"Position ({size}) überschreitet 10% Cap ({max_allowed})" def test_normal_case_returns_sensible_value(self): """Normale Parameter → vernünftige Positionsgröße.""" # Portfolio: $1000, Risk: 1.5% # Entry: $100, SL: $95 (5% Abstand) # risk_amount = 1000 * 0.015 = $15 # units = 15 / 5 = 3 # position_value = 3 * 100 = $300 → aber Cap bei $100 (10% von $1000) size = calculate_position_size(100.0, 95.0) assert size > 0, "Positionsgröße muss positiv sein" assert size <= config.PORTFOLIO_VALUE_USD * 0.10 assert size == round(size, 2), "Positionsgröße soll auf 2 Stellen gerundet sein" def test_position_size_positive(self): """Positionsgröße darf nie negativ sein.""" size = calculate_position_size(500.0, 490.0) assert size >= 0.0