Download tests/test_validator.py from Agueli/trading-bot: direct link, hf CLI and curl.
- Browser
- Download file 12.1 kB
-
https://huggingface.co/Agueli/trading-bot/resolve/main/tests/test_validator.py
- Command line
-
hf download hf://Agueli/trading-bot/tests/test_validator.py
-
curl -L -o test_validator.py https://huggingface.co/Agueli/trading-bot/resolve/main/tests/test_validator.py
12.1 kB
| """ | |
| 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 | |