trading-bot / tests /test_validator.py
aalienau-cmyk
Formatierungsanpassungen in Testdateien
ae0135c
Raw History Blame Contribute Delete
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